Java 17Spring BootPostgreSQLJUnit 5Testcontainers

INCACORE: cómo corregí actualizaciones perdidas en PostgreSQL

Un problema de concurrencia que las pruebas unitarias no revelaron: lecturas obsoletas, bloqueo de filas y 10 escenarios de integración con PostgreSQL y Testcontainers.

Edgar Camberos
Edgar Camberos
5 nov 20265 min de lectura

Código y evidencia relacionada

Enlaces directos a implementaciones, repositorios o recursos desplegados.

El contexto

INCACORE nació en una simulación profesional. Su backend Spring Boot registra movimientos de inventario que pueden afectar varios productos. Al preparar pruebas de integración, quería comprobar dos cosas: que un error no dejara escrituras parciales y que dos movimientos simultáneos no perdieran actualizaciones.

Primero: una transacción no basta

Añadí @Transactional a la creación y eliminación de movimientos para que los cambios se confirmaran o revirtieran como una unidad. Las primeras pruebas con PostgreSQL comprobaron el rollback cuando fallaba el segundo producto después de actualizar el primero.

Sin embargo, dos movimientos concurrentes todavía podían leer la misma cantidad inicial. Si ambos calculaban su resultado desde ese valor, uno sobrescribía el cambio del otro: una actualización perdida.

El detalle estaba en la lectura

La primera consulta de Hibernate obtenía el valor antes de aplicar el bloqueo de escritura. Tener una anotación de bloqueo no ayudaba si el cálculo ya partía de una lectura obsoleta.

Corregí la consulta del repositorio para adquirir PESSIMISTIC_WRITE sobre la fila de stock en una sola consulta, sin joins que provocaran la lectura previa. El servicio obtiene ese stock bloqueado dentro de su transacción y después calcula el movimiento.

Cuando un movimiento modifica varios productos, adquiere los bloqueos por ID de producto ascendente. Esto reduce la posibilidad de interbloqueos; no significa que cualquier bloqueo sea imposible.

La prueba que cambió mi confianza

Con JUnit 5 y Testcontainers ejecuté PostgreSQL 16 real. La batería final contiene 10 escenarios: creación y eliminación exitosas, rollback ante errores intermedios, movimientos concurrentes sobre el mismo stock y alertas por correo.

También desactivé la transacción automática del test de JPA para comprobar las transacciones del servicio, no una protección proporcionada por la propia prueba.

El correo debe esperar al commit

La alerta de stock ahora se publica mediante un evento y un listener AFTER_COMMIT. Si el movimiento se revierte, no se envía el correo. Un error del proveedor tampoco deshace un inventario ya confirmado.

El compromiso es explícito: el envío sigue siendo de mejor esfuerzo. Si necesitara reintentos durables y entrega garantizada, el siguiente paso sería un outbox transaccional.

Lo que aprendí

Atomicidad, control de concurrencia y efectos externos son problemas diferentes. Las pruebas unitarias ayudan con las reglas; para comprobar el comportamiento real de transacciones y bloqueos necesitaba PostgreSQL. Ese fue el valor de añadir pruebas de integración antes de considerar terminado el flujo.

Edgar Camberos

Java Backend Developer · Spring Boot