Saltar al contenido principal
BACK-501 • 34 Horas • Nivel Avanzado / Principal Node.js Runtime Architect

Node.js & TypeScript Enterprise Internals: libuv Event Loop, V8 GC, Worker Threads y Arquitectura Hexagonal

Fases internas de libuv y colas de microtareas (nextTick vs Promise), optimización de Hidden Classes en V8, paralelismo con Worker Threads y SharedArrayBuffer/Atomics, Backpressure en Streams y Arquitectura Hexagonal en NestJS.

Módulo 1: Módulo 1: Internals del Runtime Node.js: Motor V8, libuv Event Loop y Colas de Microtareas

Anatomía de las 6 Fases del Event Loop en libuv, epoll/IOCP y Inanición de I/O por Microtasks (55 min)

Anatomía de las 6 Fases del Event Loop en libuv, epoll/IOCP e Inanición de I/O por Microtasks

1. El Ciclo Determinista de 6 Fases de uv_run() en C

Contrario al mito popular, el Event Loop de Node.js no es una única cola FIFO, sino una máquina de estados cíclica implementada en libuv que recorre seis fases estrictas, drenando entre cada fase y entre cada callback las dos colas de microtareas de alta prioridad (process.nextTick seguida de la cola de Promise de V8):

flowchart TD
    T["1. Timers Phase (Min-Heap: setTimeout / setInterval vencidos)"] --> P["2. Pending Callbacks (Errores TCP diferidos ECONNREFUSED)"]
    P --> ID["3. Idle, Prepare (Uso interno libuv)"]
    ID --> POLL["4. Poll Phase (epoll_wait / kqueue / IOCP: I/O de Red y Sockets)"]
    POLL --> CHK["5. Check Phase (setImmediate callbacks)"]
    CHK --> CLS["6. Close Callbacks (socket.on('close'))"]
    CLS --> T

2. Por qué UV_THREADPOOL_SIZE NO Afecta a Sockets TCP/HTTP pero Sí a DNS y Criptografía

- Red TCP/UDP/HTTP: Es $100\%$ no bloqueante a nivel de kernel (epoll en Linux, kqueue en macOS, IOCP en Windows). No utiliza el Thread Pool de libuv. - Operaciones que SÍ compiten por el Thread Pool de libuv (por defecto solo $4$ hilos): 1. fs.* (todas las operaciones asíncronas de sistema de archivos local). 2. dns.lookup() (invoca getaddrinfo(3) síncrono de libc; en alta carga bloquea los $4$ hilos si no se usa dns.resolve4() o caché DNS). 3. crypto.pbkdf2, crypto.scrypt, crypto.randomBytes y zlib.

3. Detector de Bloqueo de Event Loop y Lag p99 en Producción

import { monitorEventLoopDelay } from 'perf_hooks';

export interface EventLoopHealthSnapshot { p50LagMs: number; p99LagMs: number; maxLagMs: number; shouldShedLoad: boolean; }

export class EventLoopCircuitBreaker { private histogram = monitorEventLoopDelay({ resolution: 10 });

constructor(private readonly maxAllowedP99Ms = 85) { this.histogram.enable(); }

public inspect(): EventLoopHealthSnapshot { const p50LagMs = Number((this.histogram.percentile(50) / 1e6).toFixed(2)); const p99LagMs = Number((this.histogram.percentile(99) / 1e6).toFixed(2)); const maxLagMs = Number((this.histogram.max / 1e6).toFixed(2));

this.histogram.reset();

return { p50LagMs, p99LagMs, maxLagMs, shouldShedLoad: p99LagMs > this.maxAllowedP99Ms }; } }

Internals del Motor V8: Hidden Classes (Maps), Inline Caching (Monomorphic vs Megamorphic) y Orinoco GC (55 min)

Internals del Motor V8: Hidden Classes (Maps), Inline Caching (Monomorphic vs Megamorphic) y Orinoco GC

1. De Objetos Dinámicos a Desplazamientos Fijos en C++: Hidden Classes (Map) e Inline Caches

JavaScript carece de estructuras con layout de memoria estático en tiempo de compilación. Para evitar buscar propiedades en un diccionario hash en cada lectura obj.amount, V8 asigna a cada objeto una Hidden Class (internamente llamada Map) que registra el offset exacto en bytes de cada propiedad:

| Estado del Sitio de Llamada (Inline Cache) | Número de Hidden Classes (Shapes) Vistas | Mecanismo de Resolución en TurboFan JIT | Rendimiento Relativo | | :--- | :--- | :--- | :--- | | Monomorphic ($1$ Shape) | Exactamente $1$ estructura de propiedades en el mismo orden | Lectura directa por offset de memoria en $1$ instrucción de ensamblador (mov rax, [rdi + 0x18]) | $100\%$ (Óptimo) | | Polymorphic ($2$ a $4$ Shapes) | Entre $2$ y $4$ variantes | Árbol de decisión lineal comparando el puntero de Map | $\approx 65\%$ | | Megamorphic ($\ge 5$ Shapes) | Más de $4$ estructuras o propiedades agregadas en orden aleatorio / delete obj.prop | Búsqueda costosa en tabla global / Hash Lookup + Deoptimización (Bailout) de TurboFan | $< 12\%$ (Degradación severa) |

2. Arquitectura Generacional del Recolector de Basura de V8 (Orinoco)

1. New Space (Young Generation — $1\text{ MB}$ a $64\text{ MB}$): Dividido en dos semiespacios (From-Space y To-Space). El recolector Scavenger aplica el algoritmo de Cheney copiando únicamente los objetos vivos; como el $90\%$ de los objetos HTTP mueren inmediatamente (Generational Hypothesis), los objetos muertos tienen costo de limpieza cero. 2. Old Space (Tenured Generation): Objetos que sobreviven a $2$ ciclos de Scavenger son promovidos al Old Space, recolectado mediante Mark-Sweep-Compact concurrente e incremental.'

Ingeniería de Memoria Fuera del Heap V8: Buffer Pool, Streams Pipeline y Control Matemático de Backpressure (50 min)

Ingeniería de Memoria Fuera del Heap V8: Buffer Pool, Streams Pipeline y Control Matemático de Backpressure

1. Cómo un Endpoint de Exportación de 2 GB Tumba un Pod de 512 MB de RAM

Si un controlador lee un cursor de base de datos o un archivo de S3 a $300\text{ MB/s}$ y lo envía a un cliente móvil cuya red 4G descarga a $2\text{ MB/s}$, ignorar el valor booleano de retorno de writable.write(chunk) acumula los $298\text{ MB/s}$ excedentes en memoria RAM no recolectable hasta provocar un OOM Kill (Exit Code 137).

sequenceDiagram
    participant DB as Readable Stream (PostgreSQL Cursor 300 MB/s)
    participant PIPE as stream.pipeline() Controller
    participant RES as Writable HTTP Socket (Cliente 2 MB/s)
    DB->>RES: writable.write(chunk)
    RES-->>PIPE: Retorna FALSE (Buffer > highWaterMark 64KB)
    PIPE->>DB: readable.pause() (Detiene lectura en BD)
    Note over RES: El cliente drena el buffer TCP...
    RES-->>PIPE: Evento 'drain'
    PIPE->>DB: readable.resume()

2. Uso Obligatorio de stream/promises pipeline para Prevenir Fugas de Descriptores

Si un cliente cierra la pestaña del navegador a mitad de una transferencia usando .pipe() clásico, los streams aguas arriba no se destruyen automáticamente, dejando cursores de PostgreSQL o archivos abiertos permanentemente. La función nativa await pipeline(source, transform, destination) propaga la cancelación y libera el $100\%$ de los recursos en cualquier dirección.

Módulo 2: Módulo 2: Paralelismo Real en CPU: Worker Threads, SharedArrayBuffer, Atomics y AsyncLocalStorage

Paralelismo Multi-Core con Worker Threads: SharedArrayBuffer, Operaciones Atómicas de CPU y Ring Buffers (55 min)

Paralelismo Multi-Core con Worker Threads: SharedArrayBuffer, Operaciones Atómicas de CPU y Ring Buffers

1. El Costo Oculto de postMessage(): Structured Clone Algorithm

Enviar un objeto JSON de $25\text{ MB}$ hacia un Worker mediante worker.postMessage(obj) serializa y copia todos los bytes entre los Isolates de V8, bloqueando el hilo principal durante decenas de milisegundos. Para lograr transferencia Zero-Copy ($O(1)$): 1. Transferable Objects (ArrayBuffer): Transfiere la propiedad del puntero de memoria C++ al Worker invalidando el buffer en el hilo emisor en $< 0.01\text{ ms}$. 2. Memoria Compartida Concurrente (SharedArrayBuffer + Atomics): Ambos hilos leen y escriben sobre las mismas páginas físicas de RAM sincronizados mediante instrucciones atómicas de procesador (LOCK CMPXCHG / Atomics.compareExchange, Atomics.wait, Atomics.notify).

/
 * Adquisición de Spinlock / Futex no bloqueante sobre SharedArrayBuffer
 * utilizando Compare-And-Swap (CAS) de hardware vía Atomics.
 */
export class SharedMemoryMutex {
  private readonly view: Int32Array;
  private static readonly UNLOCKED = 0;
  private static readonly LOCKED = 1;

constructor(sharedBuffer: SharedArrayBuffer, byteOffset = 0) { this.view = new Int32Array(sharedBuffer, byteOffset, 1); }

public tryAcquire(): boolean { const prev = Atomics.compareExchange( this.view, 0, SharedMemoryMutex.UNLOCKED, SharedMemoryMutex.LOCKED ); return prev === SharedMemoryMutex.UNLOCKED; }

public release(): void { Atomics.store(this.view, 0, SharedMemoryMutex.UNLOCKED); Atomics.notify(this.view, 0, 1); } }

Dimensionamiento Matemático de Worker Pools: Ley de Amdahl, Ley de Little y Colas Acotadas con Load Shedding (50 min)

Dimensionamiento Matemático de Worker Pools: Ley de Amdahl, Ley de Little y Colas Acotadas con Load Shedding

1. Por qué Crear un new Worker() por Petición Destruye el Servidor

Instanciar un Worker nuevo arranca un V8 Isolate completo desde cero ($\approx 30\text{ ms}$ de tiempo de arranque y $\approx 35\text{ MB}$ de RAM base). En producción se mantiene un Fixed Worker Pool pre-calentado de tamaño $N_{\text{workers}} = N_{\text{vCPU}} - 1$ (reservando $1$ núcleo exclusivo para el Event Loop de red I/O).

2. Aplicación de la Ley de Little ($L = \lambda \cdot W$) para Acotar la Cola de Tareas

Si cada tarea intensiva en CPU (ej. firma criptográfica XML, generación de PDF o inferencia ONNX) tarda $W = 50\text{ ms}$ en un pool de $4$ cores ($80\text{ tareas/s}$ de capacidad máxima) y tu SLA máximo de espera en cola es $T_{\text{max\_queue}} = 250\text{ ms}$, el tamaño máximo de la cola en memoria debe acotarse estrictamente a:

$Q_{\text{max}} = \text{Throughput}_{\text{max}} \times T_{\text{max\_queue}} = 80 \times 0.25 = 20 \text{ tareas}$

Cualquier petición $21$ debe rechazarse instantáneamente con HTTP 429 / 503 Retry-After (Fast Load Shedding) en lugar de encolar miles de promesas hasta agotar la RAM.'

Propagación de Contexto Asíncrono Sin Fugas: AsyncLocalStorage para Trazabilidad W3C y Aislamiento Multi-Tenant (50 min)

Propagación de Contexto Asíncrono Sin Fugas: AsyncLocalStorage para Trazabilidad W3C y Aislamiento Multi-Tenant

1. El Equivalente Seguro a Thread-Local Storage en un Entorno Single-Threaded

En Java o C# tradicional, un hilo atiende una petición y guarda el tenantId en una variable ThreadLocal. En Node.js, un único hilo entrelaza $5,000$ peticiones simultáneas. AsyncLocalStorage (del módulo nativo node:async_hooks) propaga un almacén inmutable a través de toda la cadena de promesas, timers y callbacks de una petición HTTP sin contaminar peticiones vecinas y sin ensuciar las firmas de las funciones de dominio con parámetros de infraestructura.

Módulo 3: Módulo 3: Sistema de Tipos Avanzado en TypeScript y Arquitectura Hexagonal (Ports & Adapters)

Ingeniería de Tipos de Nivel Compilador: Branded/Nominal Types, Uniones Discriminadas Exhaustivas y Varianza (55 min)

Ingeniería de Tipos de Nivel Compilador: Branded/Nominal Types, Uniones Discriminadas Exhaustivas y Varianza

1. Superando el Tipado Estructural de TypeScript con Branded Types (Costo Cero en Runtime)

Dado que TypeScript utiliza tipado estructural (Duck Typing), si defines type UserId = string y type AccountId = string, el compilador permite pasar accidentalmente un UserId donde una función bancaria esperaba un AccountId o sumar UsdCents con ClpPesos.

Al intersectar el tipo primitivo con una marca fantasma única (unique symbol), creamos Tipos Nominales (Branded Types) que existen únicamente en tiempo de compilación ($0\text{ bytes}$ de sobrecarga en memoria V8):

declare const __brand: unique symbol;
export type Brand = T & { readonly [__brand]: B };

export type AccountId = Brand; export type CustomerId = Brand; export type ClpAmount = Brand;

export function makeAccountId(raw: string): AccountId { if (!/^ACC-[0-9]{10}$/.test(raw)) { throw new Error(Formato inválido de AccountId: ${raw}); } return raw as AccountId; }

export function transferClp( source: AccountId, destination: AccountId, amount: ClpAmount ): { txRef: string } { if (amount <= 0n) throw new Error('El monto debe ser positivo'); return { txRef: TX-${source}->${destination}:${amount.toString()} }; }

2. Verificación Exhaustiva en Tiempo de Compilación con el Tipo never

export type PaymentState =
  | { readonly status: 'AUTHORIZED'; readonly authCode: string }
  | { readonly status: 'CAPTURED'; readonly settledAtIso: string }
  | { readonly status: 'DECLINED'; readonly declineReason: string };

export function assertUnreachable(x: never): never { throw new Error(Estado no manejado exhaustivamente: ${JSON.stringify(x)}); }

Arquitectura Hexagonal Estricta (Ports & Adapters) y Desacoplamiento Total del Dominio en NestJS (55 min)

Arquitectura Hexagonal Estricta (Ports & Adapters) y Desacoplamiento Total del Dominio en NestJS

1. La Regla de Dependencia Concéntrica (Cero Imports de Infraestructura en el Dominio)

En una Arquitectura Hexagonal (Alistair Cockburn) real, la capa de Dominio (Entidades, Value Objects, Eventos de Dominio y Puertos de Salida como interfaces TypeScript puras) no tiene permitido importar absolutamente nada de @nestjs/*, typeorm, prisma, pg ni axios:

flowchart LR
    subgraph "Driving Adapters (Entrada)"
        HTTP["NestJS REST / GraphQL Controller"]
        KFK_IN["Kafka Consumer Adapter"]
    end
    subgraph "Hexágono de Aplicación y Dominio Puro"
        UC["Application Use Case (Command Handler)"]
        DOM["Aggregate Root & Value Objects (100% TS Puro)"]
        PORT["Outbound Port Interface (Symbol Token)"]
    end
    subgraph "Driven Adapters (Salida)"
        PG_REPO["PostgreSQL Repository Adapter"]
        STRIPE["Payment Gateway Adapter"]
    end
    HTTP --> UC
    KFK_IN --> UC
    UC --> DOM
    UC --> PORT
    PG_REPO -.->|Implementa| PORT
    STRIPE -.->|Implementa| PORT

Esto permite ejecutar el $100\%$ de las pruebas unitarias de reglas de negocio en $< 200\text{ ms}$ en memoria sin levantar contenedores ni mocks frágiles de ORM.

Railway Oriented Programming: Mónada Result<T, E> Tipada vs Excepciones No Controladas (try/catch) (50 min)

Railway Oriented Programming: Mónada Result Tipada vs Excepciones No Controladas (try/catch)

1. Por qué throw new Error() es un GOTO Invisible para el Compilador de TypeScript

En TypeScript, la firma async function chargeCard(dto: ChargeDto): Promise oculta por completo qué fallos de negocio pueden ocurrir (fondos insuficientes, tarjeta expirada, límite antifraude). Además, construir la pila de llamadas (Stack Trace) al hacer throw new Error() en V8 es hasta $30\times$ más costoso en CPU que retornar un objeto discriminado:

export type Result =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly error: E };

export const Ok = (value: T): Result => ({ ok: true, value }); export const Err = (error: E): Result => ({ ok: false, error });

Módulo 4: Módulo 4: Resiliencia en Producción, Graceful Shutdown, Auditoría SQL y Certificación

Protocolo de Apagado Limpio (SIGTERM Graceful Shutdown), Drenaje de Conexiones y Cazador de Fugas de Memoria (50 min)

Protocolo de Apagado Limpio (SIGTERM Graceful Shutdown), Drenaje de Conexiones y Cazador de Fugas de Memoria

1. Qué Ocurre Exactamente Cuando Kubernetes Envía SIGTERM durante un Despliegue

Cuando Kubernetes reemplaza un pod, envía la señal SIGTERM y simultáneamente inicia en paralelo la actualización asíncrona de iptables / EndpointSlice en los nodos del clúster (que tarda entre $1$ y $3\text{ segundos}$ en propagarse). Si tu proceso Node.js ejecuta process.exit(0) inmediatamente al recibir SIGTERM, cortarás en seco las transacciones en vuelo y rechazarás con error 502 Bad Gateway las peticiones que el Ingress siga enviando durante esos 3 segundos.

Secuencia Obligatoria de Drenaje Zero-Downtime

1. Al recibir SIGTERM, marcar el endpoint de /health/readiness como HTTP 503 y esperar $3\text{ a }5\text{ segundos}$ (Readiness Propagation Delay) mientras se siguen atendiendo peticiones. 2. Invocar server.close() para dejar de aceptar nuevas conexiones TCP mientras las peticiones HTTP en vuelo terminan su ejecución. 3. Hacer flush de productores Kafka, cerrar el pool de PostgreSQL (await pool.end()) y salir con código 0.

Laboratorio Práctico SQL: Diagnóstico de Saturación de Event Loop, Fugas en Old Space V8 y Load Shedding (60 min)

Laboratorio Práctico SQL: Diagnóstico de Saturación de Event Loop, Fugas en Old Space V8 y Load Shedding

Contexto del Laboratorio

Monitoreas una flota de microservicios Node.js en producción mediante la tabla nodejs_runtime_telemetry (service_name, pod_id, event_loop_p99_ms, v8_old_space_used_mb, v8_old_space_limit_mb, uv_threadpool_queued).

Reglas de Negocio a Consultar

Para cada service_name, calcula: 1. pods_count: cantidad de pods reportando (COUNT(*)::INT). 2. max_loop_lag_p99_ms: el máximo event_loop_p99_ms entre los pods del servicio (MAX(event_loop_p99_ms)::INT). 3. max_heap_usage_pct: el porcentaje máximo de uso del Old Space de V8 ROUND(MAX((v8_old_space_used_mb::NUMERIC / v8_old_space_limit_mb) * 100.0), 2). 4. total_uv_queued: suma de operaciones encoladas esperando el Thread Pool de libuv (SUM(uv_threadpool_queued)::INT). 5. runtime_action: - 'CRITICAL_HEAP_LEAK_RESTART_DRAIN' si max_heap_usage_pct >= 88.00. - 'OFFLOAD_CPU_TO_WORKER_THREADS' si no es Heap Leak pero max_loop_lag_p99_ms >= 100. - 'INCREASE_UV_THREADPOOL_SIZE' si no cumple las anteriores pero total_uv_queued >= 20. - 'OPTIMAL_V8_RUNTIME' en caso contrario. 6. Ordena el resultado por max_loop_lag_p99_ms DESC.

Examen de Certificación: Principal Node.js Runtime & TypeScript Hexagonal Architect (BACK-501) (45 min)

Examen de Certificación: Principal Node.js Runtime & TypeScript Hexagonal Architect (BACK-501)

Demuestra tu dominio técnico sobre el Event Loop de libuv, el motor V8 (Hidden Classes y GC), paralelismo con Worker Threads / SharedArrayBuffer, Streams con Backpressure y Arquitectura Hexagonal en TypeScript.