ADR-024 — Concurrency hardening: bloqueo optimista + serialización de ejecución por campaña¶
Status: accepted (2026-08-22; reabierto por judgment-day externo — PF-001/002/003)
Context: Un análisis adversarial de 4 jueces ciegos encontró carreras de concurrencia
que las pasadas de QA single-threaded no habían detectado. Confirmadas en código:
CRIT-1: PremioService.registrarEntrega/pasarASuplente/devolverAlStock/marcarNoOtorgado
leen la unidad con findById (sin lock) y ninguna entidad mutable tiene @Version.
Dos operadores sobre la misma unidad → lost update (unidad "disponible" tras entregarla,
o asociada a dos resultados). Los locks previos (ADR JD-A3) cubrieron asignar/ejecutar/
desasignar pero NO la fase de entrega.
CRIT-3: SorteoService.ejecutar toma lock pesimista del sorteo (findByIdForUpdate
(sorteoId)), no de la campaña. Dos sorteos distintos de la misma campaña pueden
ejecutarse a la vez; la consulta de adjudicados (AdjudicacionCampaniaService) lee estado
commiteado, así que ninguno ve al ganador del otro → un DNI gana en ambos, violando la
exclusión (ADR-003) y las bases legales.
Alternatives:
Solo pesimista puntual (lo que había): insuficiente — no cubre la fase de entrega ni
serializa ejecuciones entre sorteos de una campaña.
@Version (optimista) en las entidades mutables + serialización de la ejecución a nivel
campaña (elegida): defensa en profundidad. El optimista atrapa cualquier lost update
de la fase de entrega con OptimisticLockException; la serialización por campaña garantiza
la exclusión bajo ejecución concurrente.
Decision:
@Version Long version en PremioUnidad, AsignacionPremio, ResultadoSorteo (migración
V9 agrega la columna version con default 0). Un OptimisticLockException en la fase de
entrega se traduce a 409 ("otra operación modificó el premio; reintentá") vía
GestorExceptionHandler — nunca un lost update silencioso.
SorteoService.ejecutar toma primero un lock pesimista de la fila campania
(campaniaRepository.findByIdForUpdate) además del lock del sorteo. Así dos ejecuciones de
sorteos distintos de la MISMA campaña se serializan: la segunda espera a que la primera
commitee sus ganadores, y la exclusión de adjudicados (ADR-003) los ve.
Validación vigenciaDesde < vigenciaHasta en CampaniaRequest (400) y en DB (CHECK, V9).
resolver sin campaña vigente → respuesta limpia (caso C / genérica), nunca 500 colgante.
Consequences: Escrituras de la fase de entrega se vuelven seguras ante concurrencia sin
bloquear a los lectores; las ejecuciones de una campaña se serializan (impacto nulo en la
operación real — los sorteos se ejecutan manualmente uno por vez, ADR-009). El costo es una
columna version por entidad y un lock de campaña por ejecución.
[x] AC-40 — Entrega y devolución concurrentes sobre la MISMA unidad: exactamente una gana; la
otra recibe 409 por conflicto optimista; el estado final es consistente (nunca Disponible
tras Otorgado, ni dos resultados con la misma unidad).
[x] AC-41 — Dos sorteos distintos de la misma campaña ejecutándose concurrentemente se
serializan por el lock de campaña; ningún DNI adjudicado en el primero aparece como
ganador en el segundo.
[x] AC-42 — Campaña con vigencia_desde >= vigencia_hasta → 400 (validación de request) y la
DB rechaza el insert directo (CHECK).
[x] AC-43 — resolver/padron alta sin campaña vigente → respuesta definida (caso C o
mensaje genérico), nunca 500 que deje la landing colgada.