StressBench (pos-swarm) — load-test E2E¶
StressBench es la herramienta de prueba de carga del ecosistema: simula ~200 terminales de autoservicio haciendo ventas completas y concurrentes contra el backend real (AutoCompraMod), con un gateway de pago falso configurable en vivo y un cockpit web propio para ver el enjambre y manejarlo. Sirve para asegurar que el sistema aguanta carga realista. Vive en el repo pos-swarm (Node/TypeScript).
Por qué existe
El Cockpit te muestra cómo está el backend, pero no genera carga. StressBench produce la carga (200 POS vendiendo a la vez) y la cruza con la observabilidad del backend. Es la forma de validar, antes de producción, que el reconciliador drena los pagos indeterminados, que no hay doble cobro, y que las latencias (p50/p95) se mantienen estables bajo presión.
La idea en una imagen¶
graph LR
subgraph Tool["pos-swarm (un solo proceso Node/TS)"]
SWARM["Swarm<br/>200× PosMachine<br/>(ventas E2E)"]
GW["Fake gateway<br/>:9090"]
CK["Cockpit propio<br/>:4000 (HTTP + WebSocket)"]
end
SWARM -->|"REST /autocompras/v1"| BE["AutoCompraMod<br/>(real, profile loadtest)"]
BE -->|"pagos → gateway"| GW
BE -.->|"DBs lt_ (aisladas)"| DB[("lt_TsAutoCompra<br/>lt_TsMonitoring<br/>lt_TsEnvases")]
BE -.->|"catalogo/promos (solo lectura)"| REAL[("TipreRetail · TsPromos<br/>(DBs reales)")]
CK -->|"/monitoring/* cada 2s"| BE
Las tres piezas conviven en un solo proceso (npm start → src/index.ts): el swarm, el fake gateway y el cockpit comparten el event-loop de Node.
El swarm (motor de carga)¶
src/engine/swarm.ts orquesta hasta 200 máquinas virtuales (PosMachine), cada una un POS de autoservicio que corre su propio loop async.
Swarm: pre-crea las 200 máquinas idle;setTarget(n)(el slider del cockpit) define cuántas están activas;reconcile()hace ramp-up escalonado (arranca una cada ~75 ms) y drena las sobrantes;snapshot()/stateCounts()alimentan la UI.PosMachine(src/engine/posMachine.ts): una máquina de estados async que emula el flujo real de la terminal contra el backend:
stateDiagram-v2
[*] --> REGISTER
REGISTER --> OPEN: registrada + config
OPEN --> SCAN
SCAN --> SCAN: agregarArticulo (jitter 3-4s)
SCAN --> VALIDATE
VALIDATE --> PAY
PAY --> CLOSE: aprobado
PAY --> PAY: rechazado (retry, nuevo paymentAttemptId)
PAY --> PAY_INDETERMINATE: timeout / no responde
PAY_INDETERMINATE --> DONE: lo drena el reconciliador del backend
CLOSE --> DONE
DONE --> [*]
Una venta completa (runOneSale()) ejecuta los mismos endpoints REST que la terminal Flutter: openTicket → getArticulo (loop, con think-time 3-4 s) → validateTicket → payTicket (CREATE_INTENT + EXECUTE_PAYMENT, con retry) → closeTicket. Ver Flujo de compra.
Sin doble cobro: el tool NO reintenta el indeterminado
Si el pago queda indeterminado (timeout), PosMachine no reintenta — lo deja para que el reconciliador del backend lo resuelva. En cambio, un pago rechazado sí se reintenta, con un paymentAttemptId nuevo cada intento. Esto replica exactamente la regla de idempotencia de pago del POS real.
El fake payment gateway (:9090)¶
src/gateway/fakeGateway.ts implementa el mismo contrato que el gateway de pago real espera (verificado contra GatewayMPBuilder.java del backend). Mantiene un ledger en memoria de intents (estado: OBTENCIONIDTRX | PAGO_APROBADO | PAGO_DENEGADO | PAGO_CANCELADO).
| Endpoint | Paso | Qué hace |
|---|---|---|
POST /api/trxid |
CREATE_INTENT | Crea el intent (OBTENCIONIDTRX), devuelve id + qrData. |
POST /api/trxpago |
EXECUTE_PAYMENT | Aplica la decisión de pago (aprobar/rechazar). Idempotente. |
POST /api/trxanulacion |
CANCEL | Marca PAGO_CANCELADO. |
GET /api/trxes/{id} |
CHECK_STATUS | Lo usa el reconciliador del backend para resolver indeterminados. |
Escenarios de pago (configurables en vivo desde el cockpit)¶
src/gateway/behavior.ts — el comportamiento es mutable y se cambia desde la UI sin reiniciar:
| Escenario | Config | Efecto en el sistema |
|---|---|---|
| Todo aprueba | ALWAYS_APPROVE |
Camino feliz. |
| % de rechazo | RANDOM_RATE + approvalRate (ej. 80 → ~20% rechazos) |
Valida el retry-on-reject E2E. |
| Muy lento | delayMs > timeout del backend (~340 s) |
Fuerza estado INDETERMINADO. |
| No responde | neverRespond: true (cuelga el socket) |
Fuerza INDETERMINADO por timeout. |
Los escenarios "lento" y "no responde" son los valiosos: fuerzan indeterminados para verificar que el reconciliador los drena.
El cockpit propio de StressBench (:4000)¶
src/cockpit/wsHub.ts — un server HTTP + WebSocket que sirve una SPA (src/cockpit/public/index.html) y empuja estado cada 400 ms.
No confundir con el Cockpit del backend
StressBench tiene su propio cockpit en :4000 (controla el enjambre). Es distinto del Cockpit de AutoCompraMod (/autocompras/v1/cockpit/, observabilidad del backend). StressBench además consume el del backend para cruzar datos (ver abajo).

El cockpit de StressBench (en reposo, antes de arrancar): el enjambre de 200 POS (grilla con leyenda de estados), las métricas (p50/p95 por endpoint, % rechazo, HTTP 5xx, contadores del gateway), los controles (slider de POS, escenario de pago RANDOM_RATE/approvalRate/"no responde", modelo de venta JSON) y el panel "Backend (monitoring) — conectado" que cruza con /monitoring/* del backend (reconciliador, terminales, VOUCHERPENDING).
Qué muestra:
- Grilla de POS (tiles de colores por estado: OPEN, SCAN, PAY, RETRY, INDETERMINADO, CLOSE, DONE, ERROR), con tooltip de último endpoint y latencia.
- Métricas del swarm: ventas completadas, throughput DONE/min, % de rechazo de pago, indeterminados pendientes, ventas en ERROR, HTTP 5xx, y p50/p95 por endpoint (
src/metrics/collector.ts, ring buffers de 500 muestras). - Controles: slider 0–200 POS (ramp-up automático), Start/Drain/Stop, selector de escenario de pago +
approvalRate/delayMs/"no responde", y un modelo de venta editable (JSON, validado con Zod): qué EANs escanear, think-time, retries.
El panel "Backend (monitoring)": el cross-check¶
src/backend/monitoring.ts pollea cada 2 s /monitoring/{fiscal,terminals,payments} del backend y muestra un panel con el reconciliador INDETERMINADO (debe drenar bajo carga), REQUIERE_REVISION, terminales online/total, tickets VOUCHERPENDING y el estado de los backends de pago.
Por qué este panel es el corazón de la prueba
El driver solo no puede ver que el reconciliador drena los indeterminados — eso pasa dentro del backend. Cruzar la carga del swarm con /monitoring/* es lo que valida la pieza más delicada del sistema: el ciclo de pago y fiscalización bajo presión.
Aislamiento de bases: las DBs lt_¶
El backend corre con el profile loadtest (application-loadtest.yml), que apunta los gateways de pago al fake y separa los datasources:
| Módulo | DB en loadtest | Modo |
|---|---|---|
tickets |
lt_TsAutoCompra (vacía, Hibernate crea las tablas) |
escribe (todas las ventas/pagos/fiscal del test) |
monitoring |
lt_TsMonitoring |
escribe (error-sink; fail-soft si no está) |
envases |
lt_TsEnvases (copia aislada por backup/restore) |
escribe (vales) |
catalogo |
TipreRetail (la real, ~900k filas) |
solo lectura |
promos |
TsPromos (la real) |
solo lectura |
Catálogo es la DB real → usá EANs REALES
Como catalogo apunta a la base real (leer no la muta, y da volumen fiel a producción), el modelo de venta tiene que usar EANs que existan (SELECT TOP 5 cEan FROM TipreRetail.dbo.dm_Artic WHERE iNroSuc=1). Un EAN inventado da "artículo no encontrado". Para probar envases, usá EANs con envase-retornable asociado.
Los scripts en seed/ arman todo: 00_create_lt_databases.sql (crea las DBs lt_), 01_create_lt_envases.sql (copia aislada de envases, opcional), 02_create_lt_monitoring_tables.sql (tablas de error/jobs, opcional), y seed.sql (copia la config real del comercio + genera los 200 POS POS-001..200).
VOUCHERPENDING vs CLOSE en el test
Para que los tickets lleguen a CLOSE (y no queden en VOUCHERPENDING), el profile loadtest apunta app.ticket.vouchers.templatepath a un template real bundleado (seed/vouchersTemplates). El voucher se genera pero no se imprime (los SelfService no imprimen). La fiscalización usa CAEA local (no pega a AFIP); el CAEA viene sembrado en el comercio.
Cómo se corre (resumen)¶
# 1) Crear las DBs lt_ (ajustá -S host / -P password)
sqlcmd -S localhost -U sa -P <pwd> -i seed/00_create_lt_databases.sql
# 2) Backend una vez (crea tablas), Ctrl-C, y sembrar
cd ../AutoCompraMod
mvn spring-boot:run -Dspring-boot.run.arguments="--spring.profiles.active=loadtest" # luego Ctrl-C
sqlcmd -S localhost -U sa -P <pwd> -i ../pos-swarm/seed/seed.sql
# 3) El tool (fake gateway :9090 + cockpit :4000)
cd ../pos-swarm && npm install && npm start
# 4) Backend con el profile loadtest
cd ../AutoCompraMod
mvn spring-boot:run -Dspring-boot.run.arguments="--spring.profiles.active=loadtest"
# 5) Abrir http://localhost:4000 → modelo de venta + escenario de pago → slider a 200 → Start
Variables de entorno (opcionales): POS_SWARM_BACKEND (default http://localhost:8080/autocompras/v1), POS_SWARM_GATEWAY_PORT (9090), POS_SWARM_COCKPIT_PORT (4000), POS_SWARM_MAX_POS (200).
Smoke de 1 POS antes del enjambre
npx tsx scripts/smoke-one-pos.ts corre un POS por el flujo E2E completo y loguea cada paso (status + latencia). Es la forma rápida de confirmar que el setup (DBs, seed, gateway, EANs) está bien antes de levantar los 200.
Qué mirar para decir "anda ok"¶
| Señal | Bien | Mal |
|---|---|---|
| HTTP 5xx | ≈ 0 sostenido | picos → el backend no aguanta |
| Ventas completadas | suben | estancadas → bloqueo/timeout |
| % rechazo de pago | ≈ approvalRate configurado |
valida el retry-on-reject |
| Indeterminados | drenan (sube checkStatus, los tickets cierran) |
quedan colgados → reconciliador roto |
| p50/p95 por endpoint | estables en el tiempo | crecientes → leak/saturación de pool Hikari/threads |
El log del backend confirma la pieza clave: Reconciliador: ... resuelto a APROBADO.