aws.crafter.run

Fiabilidad

Saga orquestada con Step Functions

También llamado Orchestrated saga

Coordinar una operación que toca varios servicios sin transacción distribuida, ejecutando los pasos en orden y deshaciéndolos con compensaciones explícitas cuando uno falla.

DelicadoVerificado el

El problema

Confirmar un pedido son tres cosas: reservar el stock, cobrar el pago y avisar al almacén. Cada una vive en un servicio distinto, con su propia base de datos.

En un monolito con una base relacional esto era una transacción: BEGIN, tres operaciones, COMMIT. Si algo fallaba, ROLLBACK y como si nada hubiera pasado.

Aquí no existe ese ROLLBACK. No hay transacción que abarque tres servicios, así que si el pago falla después de reservar el stock, tienes veinte unidades apartadas para un pedido que no existe. Y si el aviso al almacén falla después de cobrar, has cobrado un envío que nadie va a preparar.

Escribir esto a mano en una Lambda es donde empieza el verdadero problema. El código honesto tiene esta forma:

try {
  await reservarStock(pedido);
  try {
    await cobrarPago(pedido);
    try {
      await confirmarEnvio(pedido);
    } catch { await devolverCobro(pedido); await liberarStock(pedido); throw; }
  } catch { await liberarStock(pedido); throw; }
} catch { /* ¿y si liberarStock también falla? */ }

Tres problemas, y el tercero es el grave:

La solución

Sacar el proceso del código y meterlo en una máquina de estados que persiste entre pasos.

Step Functions · StandardReservar stockCobrar pagoConfirmar envíoLiberar stockcompensaciónDevolver cobrocompensaciónCatchdeshacer en orden inversoStandard garantiza exactamente-una-vez: el cobro no se ejecuta dos veces por un reintento del flujo
Sin transacción distribuida, pero con un plan. Cada paso tiene su Catch y su compensación, declarados en la definición y no repartidos por cinco funciones. La máquina de estados recuerda por dónde iba, así que sabe exactamente qué hay que deshacer.

Cada paso es un estado con su Retry —cuántas veces, con qué backoff— y su Catch, que apunta al estado de compensación correspondiente. Las compensaciones se encadenan hacia atrás: si falla el envío, se devuelve el cobro y luego se libera el stock.

Lo que cambia respecto al try/catch anidado no es la lógica, que es la misma. Es dónde vive el progreso. Step Functions guarda el estado fuera de tu código, así que:

Por qué Standard y no Express

Para una saga con pasos no idempotentes la elección está forzada, y AWS lo dice casi con estas palabras: Standard sigue un modelo de exactamente-una-vez, lo que lo hace adecuado para orquestar acciones no idempotentes como procesar pagos. Express es al-menos-una-vez.

A eso se suma que Express no soporta .waitForTaskToken, así que cualquier saga con un paso que espera —una aprobación humana, la respuesta de un tercero— solo puede ser Standard.

Cuándo usarlo

Cuándo NO usarlo

Cómo implementarlo

  1. Ordena los pasos poniendo lo irreversible al final. Es la decisión que más reduce el número de compensaciones que hay que escribir.
  2. Escribe la compensación de cada paso antes que el paso. Si no sabes deshacerlo, todavía no sabes si puedes hacerlo.
  3. Retry por tipo de error: reintenta timeouts y throttling, nunca errores de validación — reintentar un 400 es quemar dinero.
  4. Catch hacia el estado de compensación correspondiente, encadenando hacia atrás.
  5. Haz cada paso idempotente. Los Retry hacen que se repita más, no menos.
  6. Pasa referencias, no documentos: 256 KiB entre estados.
  7. Un estado final de fallo explícito que registre el caso irrecuperable en algún sitio que un humano mire.

El código

{
  "Comment": "Saga de confirmacion de pedido",
  "StartAt": "ReservarStock",
  "States": {
    "ReservarStock": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": { "FunctionName": "reservarStock", "Payload.$": "$" },
      "Retry": [
        {
          "ErrorEquals": ["Lambda.TooManyRequestsException", "States.Timeout"],
          "IntervalSeconds": 2,
          "MaxAttempts": 3,
          "BackoffRate": 2
        }
      ],
      "Next": "CobrarPago"
    },

    "CobrarPago": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": { "FunctionName": "cobrarPago", "Payload.$": "$" },
      "Retry": [
        { "ErrorEquals": ["States.Timeout"], "MaxAttempts": 2, "BackoffRate": 2 }
      ],
      "Catch": [
        { "ErrorEquals": ["States.ALL"], "Next": "LiberarStock", "ResultPath": "$.error" }
      ],
      "Next": "ConfirmarEnvio"
    },

    "ConfirmarEnvio": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": { "FunctionName": "confirmarEnvio", "Payload.$": "$" },
      "Catch": [
        { "ErrorEquals": ["States.ALL"], "Next": "DevolverCobro", "ResultPath": "$.error" }
      ],
      "End": true
    },

    "DevolverCobro": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": { "FunctionName": "devolverCobro", "Payload.$": "$" },
      "Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 5, "BackoffRate": 2 }],
      "Next": "LiberarStock"
    },

    "LiberarStock": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": { "FunctionName": "liberarStock", "Payload.$": "$" },
      "Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 5, "BackoffRate": 2 }],
      "Next": "PedidoFallido"
    },

    "PedidoFallido": { "Type": "Fail", "Error": "SagaCompensada" }
  }
}

Fíjate en que las compensaciones llevan Retry más agresivo que los pasos normales: una compensación que falla deja el sistema inconsistente, así que merece más insistencia que la operación original.

Te va a morder

Coste

Concepto Efecto
Transiciones de estado (Standard) ~25 USD / millón
Invocaciones de Lambda una por paso, y otra por compensación
Esperar gratis — un Wait cuesta una transición, no el tiempo

La cuenta que hay que hacer antes de elegir Standard: cada paso completado es una transición facturada. Una saga de seis estados que se ejecuta un millón de veces al mes son seis millones de transiciones. Si el proceso es de alto volumen y corta duración, mira si los pasos pueden hacerse idempotentes y usar Express.

Lo que no cuesta es esperar. Un estado Wait de tres días para el plazo de devolución vale una transición, no tres días de cómputo. Comparado con mantener algo vivo, es gratis, y es la razón por la que los procesos con esperas largas acaban aquí.

Fuentes

Semántica exactamente-una-vez de Standard frente a al-menos-una-vez de Express, inmutabilidad del tipo de flujo y patrones de integración no soportados en Express: Choosing workflow type in Step Functions. Límite de 256 KiB de entrada y salida, límite de 25 000 eventos del historial y su comportamiento al alcanzarse, duraciones máximas y transiciones por región: Step Functions service quotas. Límites de TransactWriteItems como alternativa cuando todo cabe en DynamoDB: Amazon DynamoDB Transactions.

Patrones relacionados

Un enlace sin la relación nombrada es un "ver también". Aquí cada uno dice qué relación tiene y por qué.