Saltar a contenido

Pruebas de carga (stress test)

Un harness local para exprimir el gateway sin pegarle a AFIP homologación: no emite CAE reales. El foco son las tres piezas que deciden si el sistema aguanta la flota real de POS:

  1. El NumeracionLock (sp_getapplock de SQL Server) — la serialización de la numeración por punto de venta.
  2. El pool Hikari — cuántas conexiones JDBC hacen falta con N POS emitiendo a la vez.
  3. La correctitud del dedup bajo carrera — el objetivo no negociable: 0 doble-CAE.

Todo el harness es JDK puro, sin dependencias (MockArcaServer.java + StressDriver.java), y vive en la carpeta stress/ del repo. El detalle operativo completo (comandos, keystore, seed) está en stress/README.md.

Por qué un simulador ARCA propio

Pegarle a AFIP homologación para una prueba de carga no sirve: emitiría comprobantes reales y su latencia (~800 ms) taparía lo que se quiere medir (el gateway y la DB). El MockArcaServer responde WSAA LoginCms + WSFE (FECAESolicitar / FECompUltimoAutorizado / FEDummy) con latencia configurable, así se puede medir throughput puro (latencia 0) o simular AFIP real (latencia 800 ms).

Las piezas

Archivo (stress/) Qué es
MockArcaServer.java Simulador ARCA HTTPS. Siempre aprueba (no valida números ni Auth): mide el gateway + DB, no a AFIP.
StressDriver.java Driver de carga concurrente (V1/V2). Mide throughput y latencias p50/p95/p99 + errores.
application-stress.yml Perfil del gateway para la corrida: AFIP → mock, SQL Server local, Hikari dimensionado a la flota, jobs de fondo apagados.
seed-ta.sql Siembra un TA falso vigente → el gateway no hace LoginCms (no depende del .p12).
cleanup.sql Borra las Trx del stress tras la corrida.

Cómo se corre

Requisitos: JDK 17, SQL Server local con la DB tifacturaonline (con las migraciones aplicadas), y el ente del stress habilitado. Se levantan 4 procesos (ver stress/README.md para el paso a paso con el keystore):

# 1) Simulador ARCA (arg 4 = latencia AFIP en ms: 0 = throughput puro, 800 = AFIP real)
java -cp stress/out MockArcaServer 8443 stress/mockarca.jks changeit 0

# 2) Gateway apuntando al mock + SQL Server local (escucha en :18080)
java -jar target/tifactura-spring.jar \
  --spring.config.additional-location=file:stress/application-stress.yml

# 3) Driver de carga — smoke primero
java -cp stress/out StressDriver n=2000 c=32 mode=v2 ente=1 suc=1 pos=1 run=1

n total de tx · c concurrencia · mode v1|v2 · ente/suc/pos identifican el POS · run hace únicos los tickets entre corridas.

Cómo se aísla cada cosa

  • Contención del lock: mismo ente+suc+pos → todas las tx compiten por el mismo sp_getapplock (numeracion:<ente>:<pv>:<tipo>).
  • Throughput sin contención: variar pos → PVs distintos = locks independientes, el throughput suma.
  • Dedup / carrera: dos drivers con el mismo run en paralelo → deben devolver el mismo CAE, sin filas de más.

El pool Hikari dimensionado a la flota

Este es el ajuste de configuración que motivó la prueba. El perfil application-stress.yml sube el pool:

spring:
  datasource:
    hikari:
      maximum-pool-size: ${DB_POOL_MAX:220}   # 200 POS + headroom
      connection-timeout: 30000
      leak-detection-threshold: 60000

Hallazgo: dimensionar el pool al pico de POS simultáneos

Con 200 POS emitiendo en paralelo, un pool chico entra en connection-starvation: cada emisión en vuelo retiene una conexión (el persist-before-emit inserta la Trx en su propia transacción antes de llamar a AFIP — ver El ciclo de facturación). Si hay menos conexiones que POS activos, las emisiones se encolan esperando el pool, no la DB. Por eso el perfil de stress usa 220 = 200 POS + headroom.

Para producción, la regla que dejó la prueba es: dimensionar DB_POOL_MAX al pico de POS simultáneos, no a un número redondo. El default de operación normal (20, ver Configuración y perfiles) alcanza para un parque chico; una flota grande necesita subirlo. El pool también tiene que ser coherente con el max worker threads de SQL Server.

Además, el perfil apaga los jobs de fondo (spring.quartz.auto-startup: false) para que la reconciliación, PvSinMovimientos y CAEA no metan contención ajena a la medición, y baja el logging a WARN (loguear por request mata el throughput).

Resultados del smoke de validación

Corrida del 2026-07-03 contra DB local con el mock en latencia 0, validando end-to-end (mock + gateway + driver + SQL Server real + NumeracionLockSqlServer):

Qué se midió Resultado
Correctitud 969 emisiones V2 al mismo PV con c=32969 filas = 969 tickets distintos = 969 comprobantes distintos = 969 aprobadas. Cero doble-CAE, cero número repetido.
Serialización por PV Mismo (ente, pv, tipo) → todos compiten por un único sp_getapplock; ~32 tx/s con latencia-mock 0.
Saturación del lock Con c≥16 al mismo PV, ~3 % pega el timeout del applock (30 s, @LockTimeout). No es doble-emisión: es la cola saturada rechazando el sobrante — el comportamiento correcto (mejor rechazar que numerar mal).
PVs en paralelo Dos PV distintos → locks independientes, el throughput suma.

El número que importa para dimensionar producción

Con latencia ~800 ms (AFIP real) y el mismo PV, la emisión cae a ~1 tx/s por punto de venta: la llamada a AFIP ocurre dentro del lock de numeración, así que el CAE de un PV es intrínsecamente secuencial. El throughput real de producción se gana repartiendo la carga entre muchos PV, no acelerando un PV. La contención por-PV es a propósito: es lo que garantiza la numeración correlativa sin doble-CAE.

Qué mirar en cada corrida

  • Throughput y latencias (p50/p95/p99) que reporta el driver.
  • Lock: con latencia AFIP > 0, subí c y observá cómo crece la p99 (la cola del sp_getapplock).
  • Hikari: si el pool se agota → esperas y timeouts de conexión; subí DB_POOL_MAX o bajá c.
  • Dedup: SELECT COUNT(*), COUNT(DISTINCT nroTicketPos) sobre las Trx del run deben dar igual.

Por dónde seguir