Node.JS [ru] | Серверный JavaScript
11.2K subscribers
171 photos
7 videos
25 files
3.58K links
Node.js, backend и AI-разработка: статьи, новости, вайбкодинг, автоматизация и работа с ИИ-агентами.

Личный блог автора - @just_genych
По вопросам рекламы или разработки: @g_abashkin

РКН: https://vk.cc/cJPGOP
Download Telegram
Утечка памяти, которую вы не заметите: rejections в for await...of и стримы

Обработка ошибок в асинхронных итераторах и стримах кажется интуитивной, но неочевидные rejections внутри генератора или метода _read() могут привести к зависанию итерации и неосвобождению памяти. В production это проявляется как постепенный рост heap-usage без видимых причин.

Почему это происходит?
Когда вы используете for await...of, движок ожидает объект { value, done } от итератора. Если внутри асинхронного генератора возникает rejection (например, от необработанного промиса), итерация может зависнуть в бесконечном ожидании, а буферы стрима или данные генератора останутся в памяти.

Пример с генератором:
async function* gen() {
yield 1;
await Promise.reject(new Error('fail'));
yield 2; // не достигнется
}
for await (const v of gen()) { } // зависнет?

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

В стримах ситуация опаснее:
const { Readable } = require('stream');
const stream = new Readable({
async read() {
const data = await riskyOp();
this.push(data);
// если rejection без this.destroy() - утечка
}
});
for await (const chunk of stream) { }

Если read() не завершится вызовом this.destroy(err), стрим не сгенерирует событие 'error' и не очистит внутренний буфер.

Практические советы:
* Всегда обрабатывайте rejections через try-catch внутри итератора и явно завершайте итерацию — throw или return.
* Для стримов используйте this.destroy(err) при ошибках в _read или предпочтительно pipeline для автоматической обработки.
* Добавьте finally для гарантированной очистки ресурсов (закрытие соединений, таймеров).
* Проверяйте утечки через heap snapshots или process.on('warning') — не стесняйтесь мониторить.

Вывод: for await...of не обеспечивает автоматической очистки, и необработанные rejections — прямой путь к утечке памяти, поэтому всегда завершайте итерацию явно.
1👍1
🤯 Девушка получила оффер в OpenAI и поделилась своим опытом поиска работы

Внутри статьи она подробно расписывает этапы собеседований, лайфхаки и делится учебными ресурсами, которые ей помогли.

Плюс девушка великодушно оставила ссылки на свой Notion с полезными заметками по математике и LLM.

✖️ xCode Journal
Please open Telegram to view this post
VIEW IN TELEGRAM
2
Когда await превращается в троттлинг: микрозадачи, которые убивают latency

Event Loop в Node.js — штука предсказуемая. Цикл фаз: таймеры, I/O, poll, check, close. И между каждой макротаской прогоняются все микротаски, которые накопились. Каждый await создаёт микротаску. В спокойном микросервисе это незаметно, но под нагрузкой очередь микрозадач переполняет Event Loop, вызывая рост латентности без нагрузки на CPU.

Проблема: microtask queue overflow
Представь: async-функция в цикле обрабатывает пачку данных:
async function processBatch(items) {
for (const item of items) {
await processOne(item);
}
}

Каждая итерация пушит микротаску. Под нагрузкой очередь вырастает до десятков тысяч. Event Loop не успевает делать тики — latency растёт, хотя CPU свободен. Типичная ошибка: думать, что async всегда безопасен, и не замечать накладных расходов.

Диагностика: как найти виновника
Симптомы:
* латентность скачет под нагрузкой, но синхронные операции легковесные
* Event Loop utilization показывает всплески в фазе microtask
* таймеры и сокеты отвечают с задержкой, хотя процессор не на 100%

Для диагностики используй:
* perf_hooks.monitorEventLoopDelay() — если задержка растёт вместе с числом микрозадач
* логирование времени обработки микротаски через хук process.nextTick()

Оптимизация: убираем лишние await
1. Не делай await в цикле, если функция синхронная. Вместо:
for (const item of items) await syncFunc(item);

Просто:
items.forEach(item => syncFunc(item));


2. Используй Promise.all с контролем конкурентности:
const CONCURRENCY = 10;
for (let i = 0; i < items.length; i += CONCURRENCY) {
await Promise.all(items.slice(i, i + CONCURRENCY).map(processOne));
}


3. Избегай await с new Promise(resolve => ...) внутри циклов — отложи resolve, если возможно.
4. Тяжёлые операции уноси в фоновую очередь (Bull или BullMQ): не грузи Event Loop тем, что можно сделать отдельно.

Почему это критично в микросервисах?
При 5000 RPS и 20 микрозадачах на каждый запрос очередь вырастает до 100к. Чистка занимает миллисекунды — для highload это вечность. Практический совет: если функция могла быть синхронной — не делай её async. async — это не модно, это накладные расходы.

Вывод:
Диагностируй перегрузку Event Loop через мониторинг задержки, а оптимизируй — удалением лишних await и контролем конкурентности в циклах.
«Тихая» утечка дескрипторов в HTTP/2: как она убивает ваш кластер Node.js

Воркер падает с EMFILE или ENOSPC. Место на диске есть, lsof ничего подозрительного не показывает. Знакомая картина?

Скорее всего, это утечка файловых дескрипторов из-за того, что HTTP/2 сессии завершаются неправильно.

В чём дело

HTTP/2 мультиплексирует несколько потоков в одно TCP-соединение. Если клиент обрывает соединение, Node.js не всегда корректно обрабатывает событие close у мультиплексора. Пулы Http2Session не очищаются, дескрипторы сокетов и TLS зависают в CLOSE_WAIT. Воркер постепенно съедает лимит — обычно это 1024 или 4096 дескрипторов — и падает.

Как найти

Простой скрипт покажет количество открытых FD:

ls /proc/$(pgrep -f node)/fd | wc -l


Если число растёт, а нагрузка не меняется — утечка на месте.

Что делать

Ставить таймауты и принудительно закрывать сессию при ошибке потока:

const http2 = require('http2');
const server = http2.createServer((req, res) => {});
server.on('session', (session) => {
session.setTimeout(30000);
session.on('timeout', () => session.close());
});


И мониторить FD с перезапуском воркера, если порог превышен:

const cluster = require('cluster');
const fs = require('fs');
const fdThreshold = 800;
if (cluster.isWorker) {
setInterval(() => {
let fdCount;
try { fdCount = fs.readdirSync(/proc/${process.pid}/fd).length; }
catch (e) { return; }
if (fdCount > fdThreshold) {
console.error(Worker ${process.pid} has ${fdCount} FDs — restarting);
process.exit(1);
}
}, 60000);
}


Без такого мониторинга вы будете гадать, почему воркеры падают без видимой причины. Таймауты, обработка ошибок потоков и перезапуск по лимиту — этого обычно хватает, чтобы проблема не возвращалась.

Вывод:
Утечка дескрипторов в HTTP/2 — системная проблема, решаемая только комбинацией таймаутов, явного закрытия сессий и проактивного мониторинга лимита FD в каждом воркере.
👍2
Теневые блокировки Event Loop от deasync: как найти и устранить
В production падает без видимых причин? Часто виноваты "теневые" блокировки event loop. Самый частый источник - deasync и её клоны вроде sync-rpc или synchd.

Механизм блокировки
Выглядит это так: ты вызываешь библиотеку, которая внутри использует deasync. Внешне код нормальный, но под капотом - блокировка на уровне C++. Event loop замирает. deasync через node-gyp модуль синхронно ждёт завершения асинхронной операции, игнорируя все остальные события.

Как найти "тень"
Самый надёжный способ - flamegraphs с --perf-basic-prof. Ищи длинные синхронные стектрейсы с deasync::run или uv_run. Второй вариант - process._getActiveHandles(). Если активные хендлы резко падают на пиках, это симптом. Можно повесить custom async_hooks и ловить destroy на таймерах, которые не срабатывают.

Production-ready решения
Очевидный путь - замена на child_process.execSync - не выход. Это та же блокировка. Рабочие варианта:
* Переписать на асинхронные аналоги
* Вынести синхронные вызовы в Worker Thread
* Вставить setImmediate после каждого вызова через timers/promises

Пример. Вместо deasync(promise) пишем await promise. Если сторонняя библиотека - заворачиваем в Worker.
const { Worker } = require('worker_threads');
const worker = new Worker('./worker.js');
// p99 latency падает с 1200ms до 45ms


Типичная ошибка
Даже один вызов deasync может заблокировать event loop на сотни миллисекунд. Под нагрузкой это смертельно. Проверьте своё production: grep -r "require.*deasync" node_modules - и вы узнаете, кто тихо убивает ваш event loop.

Вывод: Избегайте любых синхронных вызовов в production-режиме, заменяя их на асинхронные паттерны или Worker Threads, чтобы p99 latency оставалась стабильной под нагрузкой.
2
Диагностика и устранение deferred reference cycles в WeakRef и FinalizationRegistry: кэши и пулы в долгоживущих процессах

С WeakRef и FinalizationRegistry в Node.js можно получить скрытые утечки памяти, когда объекты в кэшах и пулах соединений не освобождаются из-за циклических ссылок через cleanup-функции. В production это проявляется как постепенный рост heap в долгоживущих процессах.

Ловушка deferred cycles
Основная проблема - передача объекта (например, socket) в FinalizationRegistry как heldValue внутри cleanup-функции, которая замыкается на тот же объект. Пока cleanup висит в очереди, GC не может освободить объект, так как registry его удерживает.
const registry = new FinalizationRegistry((heldValue) => {
heldValue.socket.destroy();
});

class CachedConnection {
constructor(socket) {
this.socket = socket;
registry.register(this, this); // цикл: this как heldValue
}
}


Диагностика
- Используй heap snapshots через --inspect: ищи цепочку WeakRef -> Object -> FinalizationRegistry
- Следи за process.memoryUsage().heapUsed при простое - рост без причины признак deferred cycle
- Логируй вызовы FinalizationRegistry: если cleanup не происходит после удаления всех внешних ссылок, цикл активен

Production-ориентированный совет
Для пулов соединений храни только идентификаторы в WeakRef, а реальные объекты - в отдельном Map с таймаутом.
registry.register(this, this.socket.id); // примитив, разрывает цикл

Или используй явную отписку:
registry.unregister(this) // перед удалением ссылки


Типичная ошибка
Передача в registry объекта с методами, которые держат ссылки на другие ресурсы. Это создает неявные циклы, которые GC не распознает.

Вывод: Deferred reference cycles - это скрытые утечки, которые требует диагностики через heap snapshots и разрыва замыканий через примитивы в FinalizationRegistry.
🤣 Общаться о синтаксисе теперь тоже можно через агентов

✖️ xCode Journal
Please open Telegram to view this post
VIEW IN TELEGRAM
😁2
Как temporal.io SDK ломает V8 heap snapshot в длительных Workflow

Если ваш Workflow Execution живёт неделями, готовьтесь к сюрпризам: V8 перестаёт адекватно собирать мусор, а heap snapshot превращается в помойку из ретейнеров. Частая ошибка — считать, что GC справится сам, но Temporal SDK активно мешает ему.

Почему GC не работает
SDK держит один V8 isolate на весь Worker. Пока Workflow крутится, Temporal сохраняет историю состояний и коллбэки. V8 честно чистит память, но SDK не даёт объектам уйти через замыкания в таймерах и маппингах. WeakRef и FinalizationRegistry в детерминированном коде — отдельная боль: GC просто не видит, что объекты уже никому не нужны. В результате в heap зависают ретейнеры вроде WorkflowContext и ActivityContext.

Production-кейс: Map на миллион элементов
Пишете долгий воркфлоу с Map на миллион элементов, потом ставите Workflow.sleep('1 year'). Этот Map останется в памяти до перезапуска воркера. Почему: SDK замораживает каждое состояние через Object.freeze(), и ссылки на объекты в V8 heap остаются. V8 считает их живыми, пока isolate жив — heap аккумулируется, RSS ползёт вверх, GC тормозит.

Практические советы
* Обновляйтесь до @temporalio/workflow >=1.8.0 — там появился явный context.reset().
* Чистите Map и Set руками: largeMap.clear().
* Дёргайте maxWorkflowCachedExecution, чтобы принудительно пересоздавать isolate.
* Мониторьте heap через process.memoryUsage() и рестартуйте Worker, если память перевалила за лимит.

Вывод: В длительных Workflow явное управление памятью важнее надежды на GC — закладывайте очистку структур данных и рестарты Worker в архитектуру с самого начала.
🐳2
False Sharing в SharedArrayBuffer на NUMA: скрытый убийца производительности Node.js
Параллельная обработка данных через worker_threads с SharedArrayBuffer часто даёт неожиданное падение производительности вместо ожидаемого ускорения. Причина — false sharing, который на NUMA-системах с несколькими сокетами может снижать пропускную способность в десятки раз, оставаясь незамеченным в обычных профилировщиках.

Суть проблемы
Когда два воркера пишут в разные элементы одного Int32Array, но они попадают в одну кэш-линию (64 байта = 16 int32), процессор инвалидирует кэш у соседнего ядра. Протокол MESI создаёт лавину обменов между L1-кэшами, а на NUMA это усугубляется межсокетными задержками.
// Проблемный код: data[0] и data[1] в одной кэш-линии
const sharedBuffer = new SharedArrayBuffer(4 * 1024);
const data = new Int32Array(sharedBuffer);
// Worker 1: data[0]++ | Worker 2: data[1]++


Диагностика через аппаратные счётчики
Используйте perf для выявления:
perf stat -e cache-misses,cache-references,LLC-load-misses node app.js

Резкий рост LLC misses при добавлении воркеров — явный признак. На NUMA добавьте проверку uncore_imc событий для обнаружения межсокетных обращений.

Устранение: padding и привязка к NUMA
Разделите данные на границы кэш-линии — отступ в 16 элементов для Int32Array:
const CACHE_LINE = 16;
const realIndex = workerId * CACHE_LINE;

Это устраняет конфликт, но не решает проблему доступа к памяти другого сокета. Привяжите воркеров к локальным нодам через numactl или модуль numa-bindings:
// Привязка к NUMA-ноде 0
worker.setEnvironment({ 'NODE_NUMA_NODE': '0' });


Типичная ошибка
Многие разработчики используют Atomics.store/load каждую итерацию, пытаясь синхронизировать данные. Это лишь добавляет барьеры памяти и усугубляет false sharing. Используйте редкую синхронизацию (раз в N итераций) или локальные буферы с периодическим слиянием.

Production-совет
На высоких нагрузках (8+ ядер) комбинация padding + привязка к локальной памяти даёт прирост 5-10 раз. Для 4-сокетных систем используйте распределение SharedArrayBuffer через libnuma: массив делится на сегменты по числу нод, каждый воркер пишет только в свой сегмент.

Вывод: False sharing на NUMA требует обязательного выравнивания по кэш-линии и привязки worker_threads к локальным нодам памяти — только так можно получить реальный прирост от параллельной обработки.
🐳2
Starvation в worker_threads: почему libuv заставляет ваши воркеры голодать при смешанной нагрузке I/O и CPU

Когда вы смешиваете I/O и CPU-bound задачи в worker_threads, неочевидная деталь может убить производительность. В production часто встречается ситуация: воркеры простаивают, хотя ядра свободны. Корень проблемы - приоритеты очередей libuv.

Как libuv ест процессорное время воркеров
У libuv есть I/O callback queue с высоким приоритетом. Если в главном потоке активны I/O операции (чтение файлов, сетевые запросы), они обрабатываются первыми. Worker_threads получают CPU только на этапе idle. В результате воркер может ждать 100-200 мс при реальной нагрузке, даже если на ядрах нет другой работы.

Диагностика: ловим задержки
Начните с event loop lag. Вот production-ready пример:
const { performance } = require('perf_hooks');
const start = performance.now();
setTimeout(() => {
const lag = performance.now() - start - 100;
if (lag > 50) console.warn(Event loop lag: ${lag.toFixed(2)}ms);
// Если lag растет - воркеры голодают
}, 100);

Также полезно смотреть process._getActiveHandles() - там видно, какие задачи висят в libuv. Типичная ошибка: думать, что раз воркер в отдельном потоке, он не зависит от I/O в главном.

Четыре способа исправить
1. Ограничьте частоту I/O в главном потоке. Не спамьте readFile каждые 10 мс - используйте debounce или batch.
2. Разбивайте данные на чанки с setImmediate(). Это перераспределяет приоритет очереди libuv и дает воркерам больше шансов:
function processChunks(data) {
const chunk = data.splice(0, 100);
if (chunk.length) {
setImmediate(() => processChunks(data));
}
}

3. Настройте UV_THREADPOOL_SIZE. Не ставьте максимальное значение - обычно достаточно os.cpus().length * 2, но не больше 4.
4. Выделите одно ядро главному потоку: workerCount = os.cpus().length - 1.

Важный предостережение
Даже с настройками worker_threads остается зависимым от главного event loop. Если не изолировать CPU-bound и I/O задачи в разные процессы, при пиковой нагрузке воркеры все равно будут ждать. Планирование завязано на libuv - не игнорируйте это.

Вывод: Причина starvation не в worker_threads, а в неявных приоритетах libuv - решайте её через контроль I/O в главном потоке и разбивку задач с setImmediate.
🐳21🔥1
process.nextTick: невидимый убийца event loop, который кладет production-сервисы

Кажется, что все знают про process.nextTick, но на практике я регулярно вижу, как он валит продакшен. Проблема в том, что он имеет высший приоритет в микротаск-очереди и выполняется до любого I/O и таймеров. Это делает его опасным при рекурсивном использовании.

Как рекурсивный nextTick убивает таймеры

Event loop после каждого макротаска вычищает все микротаски. process.nextTick — микротаск с наивысшим приоритетом. Рекурсия без выхода блокирует очередь:

function recursiveTick() {
process.nextTick(recursiveTick);
}
recursiveTick();


После первого макротаска event loop заходит в микротаски. Там recursiveTick добавляет новый nextTick. И так бесконечно. setTimeout и setInterval не запускаются — они в макротасках, а микротаски не кончаются.

Production-кейс с кэшем и БД

У нас система с кэшем при ошибке доступа к БД в nextTick крутила переинициализацию соединения. В ошибочном сценарии — рекурсия. Результат: health-check’и молчали (таймеры не работали), входящие запросы висели, сервис падал веером. Типичная ошибка — думать, что nextTick безопасен для retry-логики.

Практические решения

* Никогда не используйте рекурсивный nextTick. Вместо него setImmediate — он ставит задачу в следующую фазу event loop, давая I/O и таймерам шанс:

function safeRecursive() {
// логика
setImmediate(safeRecursive);
}


* Если очередь нужна, добавьте лимит итераций. После, скажем, 1000 вызовов — принудительный yield через setTimeout(fn, 0).

* Для длинных цепочек используйте библиотеки для backpressure, иначе даже без бесконечной рекурсии производительность упадет.

Вывод:
process.nextTick ломает кооперативную природу event loop, и даже короткая рекурсия может полностью парализовать I/O и таймеры в production.
11🔥1
SharedArrayBuffer без Atomics: гонка, которую не видно
Когда два worker_thread пишут в один SharedArrayBuffer, состояние памяти может разрушиться без видимых исключений. Особенно это заметно на ARM: вместо ожидаемых 0xFF00FF00 и 0x00FF00FF получается 0x0000FFFF — байты частично обновляются и перемешиваются. Частая ошибка — думать, что «просто запись» в SharedArrayBuffer безопасна без Atomics.

Почему это ломается
Два воркера одновременно пишут в соседние ячейки Uint32Array. Кэш-линии процессора конфликтуют, и результат — частичное обновление. В production это проявляется как расходящиеся данные или краши без явной причины. Диагностика сложна: console.log с двоичным выводом может показать мусор, но только постфактум.

Как диагностировать
Вставь Atomics.load() для проверки — он вернет актуальное значение и выявит «битые» данные. Но без Atomics.store() запись остается неатомарной. Atomics.load(buffer, index) — первый шаг к обнаружению гонки. Если видишь неожиданные значения, проблема точно в синхронизации.

Лечение: только Atomics
Используй Atomics.store, Atomics.load, Atomics.exchange — это единственный надежный способ. Подходят только целочисленные массивы: Int32Array, Uint32Array, BigInt64Array. Float не поддерживается. Для сложных структур применяй Atomics.compareExchange и спин-лок:
const lock = new Int32Array(sab, 0, 1);
while (!Atomics.compareExchange(lock, 0, 0, 1)) {
// spin
}
// работа с shared данными
Atomics.store(lock, 0, 0);

Atomics.isLockFree() для Int32 возвращает true — это быстрее мьютексов. Ошибка: попытка обойти Atomics через Buffer.writeInt32LE или DataView — это не атомарно, особенно на разных ядрах.

Типичная ошибка
Игнорировать атомарность на write-heavy сценариях. «Оптимизация» без Atomics кончится багом, который воспроизводится через раз. Не пытайся экономить на синхронизации — это ломает production.

Вывод: При конкурентной записи в SharedArrayBuffer из разных воркеров используй только целочисленные Atomics-методы — любая другая запись гарантирует state corruption на уровне памяти.
👍4🐳3
Hidden Class Degradation: тихий убийца производительности JSON.parse в Node.js

Каждый раз, когда JSON.parse создаёт объекты с разными ключами, V8 генерирует новые hidden classes. После 10-15 тысяч вызовов функция деоптимизируется из-за hidden class mismatch. В production это может снизить throughput на 30%.

Как это выглядит в коде
Представь high-load парсинг внешнего API с динамической структурой:
function parseUser(data) {
const obj = {};
if (data.role) obj.role = data.role;
return obj;
}
for (let i = 0; i < 100000; i++) {
parseUser({ name: "test", role: i % 2 ? "admin" : undefined });
}

V8 создаёт два разных hidden class: один с role, другой без. Через ~20 итераций цикл деоптимизирует parseUser.

Диагностика
Запусти Node c флагами:
node --trace-opt --trace-deopt app.js | grep "hide class"

Ищи строки вроде "deoptimized because of: hidden class mismatch". Если они есть — ты в зоне риска.

Три стратегии исправления
1. Фиксированная структура — инициализируй все поля заранее:
const obj = { role: null };
Hidden class один для всех экземпляров.

2. Map для динамических ключей:
const map = new Map(Object.entries(data));
Map не имеет hidden class transitions, но чтение/запись чуть медленнее.

3. Пул объектов (для high-load):
Заранее подготовь массив объектов с фиксированной структурой, переиспользуй их через reset полей. Это убирает GC и даёт V8 стабильный hidden class.

Вывод: hidden class transitions — это overhead, который легко упустить; фиксированная структура объекта или Map — дешёвый способ вернуть 30% производительности.
👍43🐳3😁1
Implicit ABA в async-генераторах: тихая гонка в очередях сообщений

Работа с очередями в Node.js — дело привычное. Kafka, RabbitMQ, Redis Streams. Берёшь async-генератор, читаешь сообщения, обрабатываешь. Но implicit ABA problem в async-генераторах выстреливает в production неожиданно: yield — точка прерывания, и пока генератор приостановлен, другой воркер может изменить состояние очереди.

Где прячется проблема

Вот production-ориентированный пример:

async function* readMessages(queue) {
let lastOffset = await queue.getLatestOffset();
while (true) {
const messages = await queue.poll(lastOffset);
if (messages.length === 0) continue;
yield messages;
for (const msg of messages) {
if (msg.offset >= lastOffset) {
lastOffset = msg.offset + 1;
}
}
}
}


Проблема: пока ты висел на yield, другой процесс мог удалить сообщение, сместить offset. Ты возвращаешься — работаешь с устаревшим состоянием. Это классическая implicit ABA, где "последний offset" меняется под капотом.

Как диагностировать

Логируй время до и после yield. Если пауза больше пары секунд при высокой конкурентности — это красный флаг. Используй детерминированные идентификаторы сообщений, не полагайся на offset как на истину. Смотри на время обработки: между итерациями риск ABA растёт.

Что делать

Самый прямой путь — транзакционная обработка с блокировкой:

async function* safeReader(queue) {
while (true) {
const result = await queue.pollWithLock();
yield result;
await queue.commit();
}
}


Совет: используй снэпшоты и проверяй их свежесть на каждой итерации. Или явно проверяй поколение очереди — если оно изменилось, рвать цикл.

Ошибка

Не пытайся кэшировать offset между yield в глобальных переменных. Это усугубляет ABA, особенно в системах с несколькими воркерами и долгими HTTP-запросами. Если есть такой код в проде — скорее всего, ты уже видел странные баги, которые не воспроизводятся локально.

Вывод: Async-генераторы элегантны, но их "заморозка" между yield требует явного управления состоянием очереди, иначе гонка становится неявной и трудноуловимой.
Deadlock из-за AbortSignal.timeout в middleware: как не убить свой сервер

Использование AbortSignal.timeout с Promise.race в цепочках middleware кажется простым способом ограничить время выполнения запроса. Однако на практике это приводит к молчаливым зависаниям, которые не ловятся даже unhandledRejection, а сервер начинает накапливать незавершённые запросы.

Симптомы проблемы
Запрос висит ровно timeout секунд, после чего падает с TimeoutError. Middleware не перехватывает ошибку — она улетает в catch верхнего уровня или теряется вовсе. Процесс не падает, но следующие запросы блокируются из-за незакрытых ресурсов.

Пример проблемного кода
app.use(async (req, res, next) => {
const controller = new AbortController();
const timeoutSignal = AbortSignal.timeout(3000);
const result = await Promise.race([
someAsyncTask(req, { signal: controller.signal }),
timeoutSignal
]);
next(); // deadlock: never called
});


Здесь AbortSignal.timeout(3000) создаёт независимый сигнал. Promise.race завершается, когда один из промисов срабатывает, но не передаёт отмену вашему controller. Дочерние задачи продолжают висеть, а middleware не вызывает next(), блокируя цепочку.

Правильное решение: единый сигнал
Используйте AbortSignal через req.signal и таймаут на уровне роутера:

app.use(async (req, res, next) => {
const controller = new AbortController();
res.on('close', () => controller.abort());
req.signal = controller.signal;
next();
});

app.use(async (req, res, next) => {
const timeout = setTimeout(() => {
req.signal.abort();
res.status(408).send('timeout');
}, 3000);
res.on('finish', () => clearTimeout(timeout));
next();
});


Диагностика в продакшене:
- Используйте async_hooks или cls-hooked для трейсинга запросов.
- Добавьте логирование в каждый middleware через console.time.
- Проверяйте открытые соединения через db.$pool.waitingClients.

Вывод: AbortSignal.timeout должен быть частью контракта middleware, а не хаотично переброшен в Promise.race, иначе deadlock обеспечен.
🔥3
process.env mutation: неявная утечка метаданных и NODE_OPTIONS в fork/exec

Многие считают process.env чем-то вроде локального словаря для конфигов, но это глобальное состояние всего процесса. Когда вы форкаете воркеры или вызываете exec, дочерние наследуют всё окружение. Одна опечатка — и пароль утекает в дамп памяти, а instrumentation.js запускается в неожиданном контексте.

Почему process.env опасен
Каждое присваивание в process.env мутирует общую память. Если вы пишете process.env.DB_PASS = 'secret', это меняет окружение для всех будущих дочерних процессов. child_process.fork() и spawn() по умолчанию наследуют process.env. Даже если воркер не использует эту переменную, она окажется в логах ошибок или core dump. Пример: production-воркер упал из-за OOM, дамп памяти содержит все переменные окружения, включая секреты. Практический совет: заменяйте process.env на локальные объекты, доступные только в родительском процессе.

NODE_OPTIONS — скрытая утечка конфигурации
Переменная NODE_OPTIONS автоматически применяется к дочерним процессам. Если вы задали process.env.NODE_OPTIONS = '--require ./instrumentation.js' в родителе, то каждый дочерний процесс исполнит этот флаг. Результат: instrumentation.js может переопределить глобальные настройки воркера или подключить модули, которые ему не нужны. Типичная ошибка: флаги для диагностики родителя попадают в production-воркеры, вызывая неожиданные побочные эффекты. Предупреждение: никогда не передавайте NODE_OPTIONS дочерним процессам. Вместо этого используйте свою переменную, например process.env.CUSTOM_FLAGS='--require ./instrumentation.js', и парсьте её внутри воркера, проверяя, что она определена.

Как защититься в production
При fork/spawn явно указывайте только нужные переменные: { env: { PATH: process.env.PATH, NODE_PATH: process.env.NODE_PATH } }. Это исключает утечку. Храните конфиги в локальных объектах с помощью WeakMap или простых переменных в замыкании. Практический совет: создайте слой конфигурации, который возвращает значения только по запросу, без глобального присваивания.

Вывод: process.env — глобальное состояние, которое легко утекает в дочерние процессы; всегда управляйте окружением явно через опцию env при fork/spawn.
1
Почему «удалёнка» съедает ROI компании: расчёты потерь

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

По отдельности это мелочи, но в команде из сотни дорогих специалистов все эти мелочи складываются в миллионы потерянной выручки за год. Цифры в статье модельные: смысл не в том, чтобы поверить процентам, а в том, чтобы подставить в формулы свои данные.

Читать далее
👎10😁5
WeakRef и циклы: тихая утечка памяти в долгоживущих процессах

WeakRef в Node.js — инструмент для умных кэшей и подписок, но в долгоживущих серверных процессах он может стать причиной неочевидной утечки памяти. Самая распространенная ошибка — циклическая структура, где WeakRef замыкается с сильными ссылками, и GC молча перестает собирать объекты.

Как возникает утечка
В серверных приложениях часто используется дерево компонентов. Если дочерний элемент хранит WeakRef на родителя, а родитель — сильную ссылку на дочерний, цикл замкнут. GC видит, что объект родителя доступен через WeakRef, но сам WeakRef жив, и сборка блокируется. Результат — плавный рост heap, незаметный неделями.

Диагностика в production
- Снимайте heap snapshots через heapdump или Chrome DevTools. Ищите раздел System / WeakRef — там лежат недоступные объекты.
- Включайте --trace-gc и --trace-gc-verbose. Они покажут, какие объекты GC не может собрать из-за слабых ссылок.
- Мониторинг process.memoryUsage() и v8.getHeapStatistics() — кривая роста без видимых причин говорит о проблеме.

Практические решения
Самый надежный способ — разорвать цикл. Используйте FinalizationRegistry для явного освобождения ресурсов:

const registry = new FinalizationRegistry((held) => {
console.log('cleaned', held);
});
class Child {
constructor(parent) {
this.parentWeakRef = new WeakRef(parent);
registry.register(this, 'child');
}
getParent() {
return this.parentWeakRef.deref();
}
}


Ошибка, которую все допускают
WeakRef внутри замыкания на this — классика. Например, в WebSocket подписках: () => this.parent.someMethod сохраняет контекст с сильной ссылкой на весь граф. Всегда пишите явную проверку: this.parentWeakRef?.deref()?.someMethod. Иначе цикл восстановится.

Вывод: WeakRef — не панацея: в долгоживущих процессах всегда проверяйте цепочки замыканий и снимайте heap snapshots, иначе тихая утечка памяти станет причиной падения сервера.
1
Диагностика и устранение indefinite suspension при мониторинге HTTP-запросов через async-hooks

Ситуация знакомая: используешь async-hooks для отслеживания длинных HTTP-запросов, и вдруг приложение начинает подвисать. Чаще всего это indefinite suspension из-за unhandled rejection.

В чём соль. async-hooks создаёт контекст для каждой асинхронной операции. Если внутри этого контекста Promise упал (rejection), а обработчика нет, хук может зависнуть в ожидании. Сборщик мусора не срабатывает, ресурсы текут — и приложение виснет.

Как ловить
Первое — глобальный перехват unhandledRejection:
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});

Второе — запускай с флагом --trace-warnings, чтобы видеть стек.

Что делать
Вариант 1. Просто и надёжно — добавить .catch() в каждый Promise внутри хука:
const hook = createHook({
init(asyncId, type) {
if (type === 'HTTPINCOMINGMESSAGE') {
const requestPromise = someAsyncOperation();
requestPromise.catch(err => console.error(Request ${asyncId} failed:, err));
}
}
});
hook.enable();


Вариант 2. Явное управление контекстами через destroy. Заводишь Map, чистишь её при завершении:
const contexts = new Map();
hook.init(asyncId, type) {
if (type === 'HTTPINCOMINGMESSAGE') {
contexts.set(asyncId, Date.now());
}
}
hook.destroy(asyncId) {
contexts.delete(asyncId);
}


Вариант 3. Принудительный таймаут. Запускаешь интервал, проверяешь застрявшие контексты (скажем, те, что висят дольше 30 секунд) и прибиваешь их.

На заметку. Если Node.js 14+, бери AsyncLocalStorage — он сам управляет контекстом. И никогда не создавай Promise внутри init без .catch(). Тестируй с --async-stack-traces.

Вывод: Indefinite suspension лечится связкой глобального обработчика ошибок и чёткого контроля жизненного цикла контекстов.
1
Код я написал за две недели. Продавать его боюсь до сих пор

Три недели назад расширение прошло ревью и появилось в Chrome Web Store. С тех пор ни анонса, ни поста, ни сообщения людям, которые его ждали. Тишина. Это уже не первый раз: в мае автор точно так же на месяц пропал в код, лишь бы не заниматься продвижением. В статье разбирается, почему разработчик прячется от собственного продукта, и приводятся замеры, что происходит с цифрами, когда прятаться перестаёшь.

Читать далее →
3
Диагностика и устранение гонок данных при конкурентном доступе к node:sqlite из worker_threads через in-memory WAL-режим

В production сценариях с worker_threads и in-memory SQLite в WAL режиме разработчики часто забывают синхронизировать доступ. Итог — SQLITE_BUSY, рассинхрон данных и дубли в уникальных полях.

Проблема: гонка без синхронизации
Несколько воркеров читают и пишут в одну in-memory базу без внешней координации. WAL помогает с параллельным чтением, но не спасает от гонок записи. Типичные проявления: ошибки SQLITE_BUSY, частичные обновления и потеря данных.

Решение: один писатель с явными блокировками
Выделите один воркер для критических записей (например, инкременты). Координируйте через SharedArrayBuffer и Atomics — это дешевле мьютексов. Воркеры-читатели используют отдельные соединения в WAL.

Production-oriented пример с BEGIN IMMEDIATE
Начиная с Node.js 23 используйте DatabaseSync. Обязательно PRAGMA journal_mode=WAL; и PRAGMA synchronous=NORMAL;:

import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:', { readwrite: true, create: true });
db.exec('PRAGMA journal_mode=WAL;');
db.exec('PRAGMA synchronous=NORMAL;');

function safeUpdate(sql) {
db.exec('BEGIN IMMEDIATE;');
try {
db.exec(sql);
db.exec('COMMIT;');
} catch {
db.exec('ROLLBACK;');
throw new Error('Write failed');
}
}


BEGIN IMMEDIATE блокирует запись сразу, избегая гонок. Без него два воркера могут начать транзакции одновременно — ловите SQLITE_BUSY.

Предупреждение: in-memory WAL не для продакшена
In-memory база теряет данные при краше. Для реальных проектов используйте файловую базу с WAL — это гарантирует персистентность.

Вывод: гонки данных в SQLite лечатся одним писателем и явными блокировками через BEGIN IMMEDIATE, давая скорость WAL без потери консистентности.
👍1