Saltar a contenido

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.

Verification

  • [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.