Skip to content

Challenge/William Cabrera#10

Open
wcabrera wants to merge 1 commit intoyaperos:mainfrom
wcabrera:challenge/william-cabrera
Open

Challenge/William Cabrera#10
wcabrera wants to merge 1 commit intoyaperos:mainfrom
wcabrera:challenge/william-cabrera

Conversation

@wcabrera
Copy link
Copy Markdown

¿Qué reto elegí y por qué?

Elegí el Challenge 2 porque es el problema que más me interesa resolver: consistencia en sistemas de pagos distribuidos sin caer en el antipatrón de la transacción distribuida. Un sistema de pagos que depende de 2PC entre servicios tiene un punto de falla que no tiene recuperación determinista cuando la red se corta a mitad de la operación.

Elegí este reto también porque me permite demostrar cómo pienso sobre trade-offs reales: no busqué la solución más completa, sino la más honesta sobre lo que garantiza y lo que no.

Decisiones arquitectónicas clave y alternativas que rechacé

  • La primera decisión fue el patrón de coordinación. Opté por saga con orquestador centralizado y rechacé la coreografía. Mi razón es práctica: cuando una transferencia falla en el paso de crédito y necesito compensar el débito, quiero que esa lógica viva en un solo lugar. Con coreografía, el estado de la saga queda distribuido entre cuatro consumidores distintos, reconstruir qué pasó y qué compensaciones ejecutar requiere correlacionar eventos de todos ellos. En un sistema de pagos eso no es aceptable desde el punto de vista de auditabilidad. El trade-off que acepto es que el orquestador es un punto de coordinación central, lo cual mitigo con idempotencia estricta y garantizando que no hay estado compartido de escritura entre servicios.

  • La segunda decisión fue el Transactional Outbox. No publico directamente a Kafka dentro de una transacción porque esa operación no es atómica: si el proceso cae entre el commit de la base de datos y el publish a Kafka, el evento se pierde y la saga queda bloqueada sin forma de recuperarse. Con el outbox, el evento se escribe en la misma transacción que el cambio de estado. El relay publica con FOR UPDATE SKIP LOCKED, lo que además permite escalar horizontalmente sin que las instancias se bloqueen entre sí. La garantía resultante es at-least-once delivery, los consumidores son idempotentes por diseño.

  • La tercera decisión, y la que más me importa comunicar, fue modelar FX_AMBIGUOUS como estado explícito. Un timeout del proveedor FX no es lo mismo que un rechazo. Si me rechaza, sé que no ejecutó y puedo compensar. Si hace timeout, no sé qué pasó, compensar podría revertir un FX que sí se procesó, generando una inconsistencia financiera. Modelé esto como un estado propio que suspende la saga sin compensar, reintenta automáticamente hasta N veces, y escala a MANUAL_REVIEW si no se resuelve. La alternativa habría sido tratar el timeout como fallo y compensar siempre, más simple de implementar, pero financieramente incorrecta.

  • La cuarta decisión fue optimistic locking con RETURNING id en vez de SELECT FOR UPDATE. Con FOR UPDATE serializo todas las operaciones sobre la misma wallet, destruyendo la concurrencia. Con optimistic locking, solo hay conflicto cuando dos transacciones tocan la misma wallet simultáneamente, y la que pierde reintenta. El RETURNING id es un detalle que aprendí a fuerza de depurar: TypeORM con PostgreSQL no expone rowCount de forma confiable para UPDATEs, verificar si el array de resultados está vacío es la única forma correcta de detectar un conflicto de versión.

Finalmente, usé Redpanda en lugar de Kafka + ZooKeeper porque Kafka tradicional requiere ZooKeeper como proceso separado. Redpanda es 100% compatible con la API de Kafka, no requiere ZooKeeper, y arranca en segundos. El código de la aplicación no cambió una línea en producción con MSK o Confluent Cloud funciona igual.

¿Qué haría diferente con más tiempo?

  • Lo primero sería Dead Letter Queue. El relay actual reintenta eventos fallidos indefinidamente. En producción, un evento que falle N veces debe ir a un tópico DLQ con alerta automática. Sin esto, un evento corrupto puede bloquear el relay silenciosamente y nadie se entera hasta que los clientes reportan transferencias estancadas.

  • Lo segundo serían tests de integración sobre infraestructura real. La arquitectura está diseñada para ser testeable los handlers no conocen Kafka ni HTTP , pero no escribí tests que levanten PostgreSQL y Kafka reales con TestContainers y verifiquen el flujo completo de compensación, la idempotencia bajo redelivery, y el comportamiento de FX_AMBIGUOUS. Esto es importante porque el bug del RETURNING id que encontré durante el desarrollo habría pasado desapercibido con mocks — solo apareció contra PostgreSQL real.

  • Lo tercero sería el endpoint de resolución para MANUAL_REVIEW. Modelé el estado correctamente y las transiciones están definidas, pero no construí el PATCH /transfers/:id/resolve que un operador usaría para decidir si completar o revertir una saga ambigua. El estado existe, falta la interfaz operacional.

Y con más tiempo pensaría en particionamiento por país. Actualmente wallets de Perú y México viven en la misma base de datos. En producción serían instancias separadas por región, y la saga necesitaría manejar latencia cross-region, fallos de partición de red entre regiones, y garantías de consistencia distintas para operaciones intra vs inter-país.

Limitaciones conocidas y atajos tomados

No implementé autenticación ni autorización. El header x-user-id se acepta sin verificar que el usuario es el dueño del wallet origen. En un sistema real hay un JWT que el API Gateway valida antes de que el request llegue a este servicio lo omití conscientemente porque está fuera del scope de la saga y habría añadido complejidad sin demostrar nada nuevo sobre el problema central.

El proveedor FX es un mock en memoria con tasas hardcodeadas y un setTimeout que simula latencia. El patrón de manejo del timeout es correcto; la integración real requeriría un HTTP client con circuit breaker y retry exponencial. No lo construí porque el objetivo era demostrar
cómo la saga maneja la ambigüedad, no cómo se integra con un proveedor externo específico.

El mapa de reintentos de FX_AMBIGUOUS vive en memoria del proceso. Si la app se reinicia durante ese estado, los reintentos no se retoman automáticamente. En producción esto iría en Redis o en un campo de la saga con un job scheduler. Es un atajo consciente el estado en base de datos es correcto y persistente, solo falta el mecanismo de reanudación tras restart.

Finalmente, no construí el backoffice para resolver estados MANUAL_REVIEW. El estado existe, las transiciones están modeladas, y sé exactamente qué endpoint haría falta decidí no construirlo porque ese trabajo pertenece al equipo de operaciones, no al servicio de transferencias en sí.

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.

1 participant