🟢 Caso 01 — Node.js 22 + node:sqlite + worker embebido#
Implementacion operativa del caso 01 para estudiar latencia bajo carga con evidencia observable, manteniendo paridad funcional con la version PHP+Postgres y Python+SQLite, pero apoyada en las primitivas naturales de Node: event loop, async/await y el modulo built-in
node:sqlite.
⬅️ Caso 01 · ⚖️ Comparativa de los 7 stacks · 🟢 Perfil de Node.js · 🧬 Todos los perfiles
🎯 Que resuelve#
Modela una API de reportes de "top customers" con dos variantes:
report-legacy: agregacion con filtro no sargable sobre la tabla transaccional + patron N+1 enriqueciendo cada fila con dos queries dependientes.report-optimized: lectura sobrecustomer_daily_summary(mantenida por un worker embebido) + un solo batch con window function para los detalles.
El escenario no se queda en un setTimeout: el SQL es real contra SQLite embebido, y un worker setInterval recalcula el resumen con DELETE + INSERT ... SELECT, dejando visible la convivencia API/batch sobre el mismo proceso.
💼 Por que importa#
Este caso muestra como se pasa de una API que "parece funcionar" a una implementacion que deja evidencia medible de por que degrada y como se corrige. La diferencia es visible en db_queries, db_time_ms y en event_loop_lag_ms — esta ultima es la senal especifica del runtime que delata el bloqueo agregado del loop por await secuencial.
🔬 Analisis Tecnico de la Implementacion (Node.js)#
El cuello de botella clasico de N+1 en Node se ve agravado por la naturaleza single-thread del runtime: una request lenta no bloquea al kernel, pero cada await cede al event loop y el costo de N round-trips se traduce en latencia agregada que degrada al resto de las requests concurrentes en el proceso.
- Implementacion Falla (
legacy):topCustomersLegacy()ejecuta una agregacion conCAST(created_at / 86400 AS INTEGER) >= ?— elCASTenvuelve la columna e invalidaidx_orders_created_customer, asi que el motor recorre las 36.000 filas deorders. Despues entra en un buclefor (const row of rows)con dos queries dependientes por iteracion: lookup de cliente yrecent_orders. El resultado es1 + 2Nejecuciones reales (db_queries_in_request: 41conlimit=20). La medicionevent_loop_lag_ms(sample entresetImmediatey la callback) crece con la presion porqueDatabaseSynces sincronico: cada query bloquea el loop mientras corre. - Sanitizacion Algoritmica (
optimized):topCustomersOptimized()resuelve el mismo conjunto con 2 queries fijas. La primera leecustomer_daily_summarycon un rango sargable sobreorder_date; la segunda trae los ultimos 3 pedidos de todos los clientes de una vez conROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC). El agrupado posterior es unMapO(1), pero sobre un resultado que ya vino resuelto del motor. - Worker concurrente:
startWorker()usasetInterval(..., 20000).unref()y ejecutarefreshSummaryOnce(), que haceDELETE FROM customer_daily_summary+INSERT ... SELECT ... GROUP BYdentro de una transaccion y registra la corrida enjob_runs. Elunref()permite que el proceso muera limpio si solo queda el timer.
🧱 Servicio#
app→ API Node.js 22 con rutas legacy y optimized, worker embebido (setInterval) y SQLite embebido vianode:sqlite, sin dependencias externas.
🚀 Arranque#
docker compose -f compose.yml up -d --buildPuerto local: 821 (modo aislado, ver opciones abajo).
Como consumir (dos opciones)#
Hub Node.js (recomendado, 8300 en compose.nodejs.yml): este caso queda servido en http://localhost:8300/01/... junto a los otros 11 casos.
Modo aislado (821 en este compose.yml): levanta solo este caso, util cuando la medicion necesita procesar limpio (sin otros casos compartiendo runtime).
🔎 Endpoints#
curl http://localhost:8300/01/
curl http://localhost:8300/01/health
curl "http://localhost:8300/01/report-legacy?days=30&limit=20"
curl "http://localhost:8300/01/report-optimized?days=30&limit=20"
curl http://localhost:8300/01/batch/status
curl "http://localhost:8300/01/job-runs?limit=10"
curl http://localhost:8300/01/diagnostics/summary
curl http://localhost:8300/01/metrics
curl http://localhost:8300/01/metrics-prometheus
curl http://localhost:8300/01/reset-metrics🧭 Que observar#
db_queriesenreport-legacycrece como1 + N + Nconlimit;report-optimizedmantiene 2 consultas independientemente dellimit;/batch/statusmuestra el estado del worker embebido y su ultima ejecucion;/diagnostics/summarycompara latencias p95 de ambas rutas y agregaevent_loop_lag_ms, la senal Node-especifica;- bajo carga concurrente real,
event_loop_lag_msse dispara parareport-legacyy se mantiene plano parareport-optimized.
⚖️ Nota de honestidad#
El SQL es real contra SQLite embebido; lo unico artificial es el round-trip (ROUNDTRIP_LEGACY_MS / ROUNDTRIP_DEFAULT_MS), que modela el hop de red que un motor remoto tendria y SQLite embebido no. El lab no benchmarkea Node contra otros runtimes: demuestra diagnostico y remediacion del patron N+1 con evidencia observable, agregando la metrica de event loop lag que es propia del runtime.
Fidelidad#
Substrato real. Este stack corre SQL contra SQLite embebido via node:sqlite (DatabaseSync, built-in desde Node 22.5 — sin npm install, sin bindings nativos). db_queries_in_request cuenta ejecuciones reales contra el motor: 1 agregacion + 2 queries por fila en la ruta legacy, 2 queries totales en la optimizada.
El unico elemento artificial es el round-trip (ROUNDTRIP_LEGACY_MS / ROUNDTRIP_DEFAULT_MS): SQLite es embebido y no tiene hop de red, asi que esos milisegundos modelan el viaje cliente-servidor de un motor remoto. El trabajo SQL de abajo no se simula.
Lo que este stack enseña y los otros no: DatabaseSync es sincronico. Cada query del N+1 bloquea el event loop mientras corre. Por eso event_loop_lag_ms no es decorativo aca — es la señal que delata que el N+1 no penaliza solo a quien lo pide, sino al throughput del proceso entero.
Para ver contencion sobre un recurso externo compartido (pool FPM contra PostgreSQL via socket TCP), ver el stack PHP (../php/README.md).