Saltar a contenido

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 startsrc/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: openTicketgetArticulo (loop, con think-time 3-4 s) → validateTicketpayTicket (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

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.