Skip to content

fix(resources): control de concurrencia optimista en PUT /resources/:id/inventory (#294) - #335

Open
nopestack wants to merge 2 commits into
GlobalEmergency:mainfrom
nopestack:fix/294-inventory-lost-update
Open

fix(resources): control de concurrencia optimista en PUT /resources/:id/inventory (#294)#335
nopestack wants to merge 2 commits into
GlobalEmergency:mainfrom
nopestack:fix/294-inventory-lost-update

Conversation

@nopestack

Copy link
Copy Markdown
Contributor

Closes #294

Problema

PUT /resources/:id/inventory sobrescribía el inventario completo sin control de concurrencia, perdiendo en silencio líneas fusionadas por POST /resources/:id/inventory-entries o el worker de donaciones mientras el formulario del dueño estaba abierto.

Solución

  • Nueva migración 0055_resource_inventory_version.sql: columna resources.inventory_version.
  • Resource trackea inventoryVersion, incrementada por replaceInventory y receiveInventory.
  • Nuevo método de repositorio saveIfInventoryVersionMatches — UPDATE condicional atómico en la misma transacción que el delete+insert de items.
  • PUT ahora requiere expectedVersion; mismatch → 409 Conflict (InventoryVersionConflictError).
  • GET /resources/:id/inventory devuelve { items, version }.
  • pnpm gen:api regenerado.
  • Web: el formulario de edición envía expectedVersion y muestra un mensaje claro en caso de 409 (recarga automática completa queda como mejora futura).

Tests

  • Dominio: version bump en replace/receive.
  • Aplicación: reproduce el escenario exacto del issue (merge concurrente no se pierde) + rechazo de retry obsoleto.
  • Integración: UPDATE condicional contra Postgres real (aplica+avanza en match, no toca storage en mismatch).
  • E2E: contrato HTTP actualizado (incluye caso 409).
  • pnpm --filter api test: 248 suites / 1603 tests. Build, lint (--max-warnings=0), prettier OK.

Fuera de alcance (seguimiento futuro)

  • Dos merges concurrentes de receiveInventory entre sí (worker vs inventory-entries) siguen siendo read-modify-write simple — no es lo que reporta este issue, pero podría necesitar su propio ticket si se manifiesta.
  • La UI no recarga automáticamente el formulario tras un 409 (solo muestra el error) — nice-to-have mencionado en el issue original.

nopestack added 2 commits July 6, 2026 09:54
…ventory (GlobalEmergency#294)

PUT /resources/:id/inventory replaced the whole declared inventory with an
unconditional delete+reinsert, so a line merged concurrently via
Resource.receiveInventory (POST /resources/:id/inventory-entries, the
donation intake worker) between the owner's form load and their save was
silently discarded.

Adds an inventoryVersion counter on resources (migration 0055), bumped on
every replaceInventory/receiveInventory. GET /resources/:id/inventory now
returns { items, version }; PUT requires expectedVersion and is rejected
with 409 (InventoryVersionConflictError) when it no longer matches — checked
both fail-fast in the use case and atomically at the storage level via a
conditional UPDATE ... WHERE inventory_version = $expected, so a real
concurrent writer can't slip through between the two checks.

The mis-puntos inventory form carries the version in a hidden field and
surfaces a clear "reload and retry" message on 409.
@nopestack
nopestack requested a review from vgpastor as a code owner July 6, 2026 08:38
@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown

@nopestack is attempting to deploy a commit to the GlobalEmergency Team on Vercel.

A member of the Team first needs to authorize it.

@vgpastor vgpastor left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

¡Gran trabajo con esto! 🙌 El control de concurrencia optimista está muy bien planteado: el UPDATE ... WHERE id=$1 AND inventory_version=$2 RETURNING es el check-and-set atómico correcto, el mirror in-memory replica la misma semántica race-free, el guard de no-op en receiveInventory está cuidado, y la cobertura de tests es ejemplar (dominio + aplicación reproduciendo el escenario exacto del issue + integración contra Postgres real + e2e con el caso 409). i18n en ambos locales, filtro de excepciones y Swagger actualizados, y gen:api regenerado. Migración idempotente e inmutable-safe. Se nota el curro. 👏

Dejo un par de observaciones para que las valores cuando puedas (ninguna la veo bloqueante):

  1. La principal (comentario en drizzle-resource.repository.ts): el candado solo cubre la ruta del PUT. save() sigue pisando items y ahora además revierte inventory_version, lo que en un escenario con una mutación no-inventario concurrente puede reabrir el propio lost-update de #294. Creo que da para un ticket de seguimiento.
  2. Nit (comentario en actions.ts): el fallback a 0 cuando falta expectedVersion no es estrictamente "siempre obsoleto" para un recurso recién registrado.

¡Gracias por el detalle en los comentarios y los tests, se revisa genial así! 🚀


Generated by Claude Code

disputed: s.disputed ?? false,
disputedAt: s.disputedAt ?? null,
disputeDismissedAt: s.disputeDismissedAt ?? null,
inventoryVersion: s.inventoryVersion ?? 0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aquí save() fija inventory_version al valor que trae el snapshot en memoria, y no es solo un no-op: puede revertir la versión y, con ello, reabrir la carrera que este PR cierra. Como save() sigue borrando+reinsertando resource_items incondicionalmente, cualquier mutación NO-inventario (cambio de estado público, verify, disputa…) que se cargara antes de un receiveInventory concurrente pisa los items y baja la versión:

  1. v5, items [A,B]
  2. El dueño abre el form PUT → lee v5
  3. Un coordinador abre "cambiar estado" → carga v5, [A,B]
  4. El worker de donaciones fusiona Cv6, [A,B,C], save()
  5. El coordinador guarda el estado → save() escribe v5 e items [A,B]C perdido y versión revertida a 5!)
  6. El dueño hace PUT con expectedVersion=5 → el WHERE inventory_version=5 coincide y el write pasa → justo el lost-update de [Resources] Lost update en PUT /resources/:id/inventory frente a merges concurrentes (receiveInventory) #294

El clobber de items por save() es preexistente, pero la reversión de la versión es nueva. No lo veo bloqueante para este PR (es un problema estructural del save() last-write-wins, más amplio que el "dos receiveInventory" que ya dejáis fuera de alcance), pero creo que merece su propio ticket de seguimiento. La solución limpia sería que save() no fije la versión al valor en memoria (que la preserve/incremente a nivel de storage, o directamente no toque items/versión cuando la mutación no es de inventario). 👍


Generated by Claude Code

// stale" (0 never matches a resource whose inventory has been touched more
// than once) so the API rejects it with 409 rather than the write silently
// going through with an undefined expectedVersion.
const expectedVersion = Number(formData.get('expectedVersion') ?? NaN);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: el comentario dice que un valor ausente/no numérico es "definitely stale" porque "0 never matches a resource whose inventory has been touched more than once". Pero en un recurso recién registrado cuya versión sigue siendo 0 (nunca tocado), este fallback a 0 sí coincidiría y dejaría pasar el write con una versión que en realidad se desconocía. Es un borde muy benigno (habría que perder el hidden field), pero la afirmación del comentario no es estrictamente cierta.

Si queréis un centinela realmente imposible, un -1 como expectedVersion de fallback nunca casaría (@Min(0) lo rechazaría con 400, o el WHERE inventory_version = -1 daría 0 filas → 409), y así "no sé la versión" nunca se confunde con "versión 0". No es imprescindible.


Generated by Claude Code

vgpastor commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Heads-up de coordinación 🔀: acabo de mergear #338 (fix #296), que toca los mismos ficheros que este PRapps/web/src/app/e/[slug]/mis-puntos/[resourceId]/inventario/actions.ts e inventory-edit-form.tsx— en la misma función saveMyInventory. Este PR va a necesitar rebase sobre main con conflicto textual casi seguro (aquí añades expectedVersion + rama 409; allí se reestructuró el bloque de parseSupplyLines a { items } | { invalidRow } y se añadió invalidRow a InventoryState). Nada grave, pero mejor rebasar antes de seguir.

Dos cosas cuando puedas:

  1. Rebase sobre main (ya con fix(web): errores de formulario accionables — fila inválida + mensajes 4xx localizados (#296) #338 dentro).
  2. Sobre la observación de la review (el save() que reescribe items y ahora revierte inventory_version, lo que puede reabrir el lost-update de [Resources] Lost update en PUT /resources/:id/inventory frente a merges concurrentes (receiveInventory) #294 vía una mutación no-inventario concurrente): ¿la dejamos como issue de seguimiento o prefieres abordarla en este mismo PR? Con que lo confirmes, avanzamos.

Gracias por el curro 🙌


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Resources] Lost update en PUT /resources/:id/inventory frente a merges concurrentes (receiveInventory)

2 participants