aws.crafter.run

Fiabilidad

Idempotencia en consumidores

También llamado Idempotent consumer

Procesar el mismo mensaje dos veces produce exactamente el mismo resultado que procesarlo una, de modo que los duplicados —que son inevitables— dejan de importar.

IntermedioVerificado el

El problema

Tienes una Lambda que consume una cola y cobra pedidos. Funciona. Lleva meses funcionando. Y un martes cualquiera, un cliente te escribe porque le has cobrado dos veces los mismos 50 €.

Revisas los logs y no hay nada: ninguna excepción, ningún reintento, ningún error. Solo dos invocaciones perfectamente normales, con el mismo contenido, separadas por unos segundos.

Sin idempotenciaSQScola-cobroscobrarPedidoAPI de pagosservicio externoentrega · msg 4711la misma, otra vezcobra 50 €cobra 50 € otra vezEl cliente ha pagado 100 € · nadie ha lanzado una excepción · no hay nada en los logs
El duplicado no es un fallo: es el contrato. SQS estándar entrega al menos una vez, y la documentación de AWS dice que puede llegar más de una copia. Un consumidor que no lo contempla no está casi bien: está roto y todavía no se ha notado.

No es un bug tuyo. Es el contrato del servicio que estás usando, escrito en su documentación y aceptado el día que elegiste una cola estándar:

“more than one copy of a message might be delivered, and messages may occasionally arrive out of order”

SQS entrega al menos una vez. SNS también. Y “al menos una vez” no es una forma educada de decir “una vez”; significa literalmente que la entrega duplicada es un comportamiento correcto del sistema, no una avería.

Las causas son de lo más rutinario:

La solución

Que el consumidor recuerde lo que ya hizo y decida en la misma operación atómica en la que lo anota.

Con idempotenciaSQScola-cobroscobrarPedidoDynamoDBidempotenciaAPI de pagoscobra una vezNo hace naday borra el mensajeentrega 1entrega 2PutItem condicionalgana la escrituraConditionalCheckFailedLa clave sale del hecho de negocio, no del messageId · el TTL limpia, pero tarde (ver trampas)
Decidir y recordar son la misma operación. La escritura condicional es atómica: no hay hueco entre comprobar si ya se hizo y anotar que se hace, así que dos entregas simultáneas del mismo mensaje no pueden ganar las dos.

Antes de hacer nada con efectos, el consumidor intenta escribir la clave del mensaje en una tabla de DynamoDB con una escritura condicional: attribute_not_exists(pk). Solo hay dos desenlaces posibles:

Lo que hace que esto funcione —y lo que se pierde en casi todas las implementaciones caseras— es que comprobar y anotar son la misma operación. Un GetItem seguido de un PutItem deja un hueco de milisegundos entre “no existe” y “ahora sí”; dos entregas simultáneas del mismo mensaje pueden colarse las dos por ese hueco. La escritura condicional no tiene hueco.

La clave importa más que el mecanismo

De qué derivas la clave decide si el patrón funciona:

Cuándo usarlo

Cuándo NO usarlo

Cómo implementarlo

  1. Elige la clave a partir del hecho de negocio, nunca del transporte.
  2. Crea la tabla con la clave como partition key y un atributo numérico para el TTL. No necesita nada más.
  3. Escribe primero, actúa después. PutItem condicional con attribute_not_exists, y solo si gana, ejecuta el efecto.
  4. Captura ConditionalCheckFailedException y trátala como éxito: el trabajo está hecho, borra el mensaje.
  5. Decide qué pasa si el efecto falla después de haber ganado la escritura (ver trampas).
  6. Pon un TTL generoso: más largo que cualquier ventana de reintento plausible, y consciente de que borra tarde.

El código

import { DynamoDBClient, ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLA = process.env.TABLA_IDEMPOTENCIA!;
const VENTANA_H = 24 * 7; // muy por encima de cualquier reintento plausible

type Resultado = 'procesar' | 'ya-hecho';

async function reservar(clave: string): Promise<Resultado> {
  try {
    await ddb.send(new PutCommand({
      TableName: TABLA,
      Item: {
        pk: clave,
        estado: 'en-curso',
        creado: Date.now(),
        expira: Math.floor(Date.now() / 1000) + VENTANA_H * 3600,
      },
      // Comprobar y anotar en la MISMA operación atómica. Un GetItem
      // seguido de un PutItem deja un hueco por el que caben dos
      // entregas simultaneas del mismo mensaje.
      ConditionExpression: 'attribute_not_exists(pk)',
    }));
    return 'procesar';
  } catch (e) {
    if (e instanceof ConditionalCheckFailedException) return 'ya-hecho';
    throw e; // Un fallo real de DynamoDB no es un duplicado: que reintente.
  }
}

export const cobrarPedido = async (evento: SQSEvent): Promise<SQSBatchResponse> => {
  const fallidos: { itemIdentifier: string }[] = [];

  for (const registro of evento.Records) {
    const pedido = JSON.parse(registro.body);
    // La clave sale del hecho de negocio, NO del messageId de SQS:
    // cuando SNS reenvia el mismo evento, el messageId es otro.
    const clave = `cobro#${pedido.id}`;

    try {
      if (await reservar(clave) === 'ya-hecho') continue;

      const recibo = await cobrar(pedido);

      await ddb.send(new UpdateCommand({
        TableName: TABLA,
        Key: { pk: clave },
        UpdateExpression: 'SET estado = :ok, recibo = :r',
        ExpressionAttributeValues: { ':ok': 'hecho', ':r': recibo.id },
      }));
    } catch (err) {
      // Si el cobro falla despues de ganar la reserva, hay que soltarla:
      // si no, el reintento vera "ya hecho" y el cobro no ocurrira jamas.
      await soltar(clave).catch(() => {});
      fallidos.push({ itemIdentifier: registro.messageId });
    }
  }

  return { batchItemFailures: fallidos };
};

Te va a morder

Coste

Barato, pero no gratis, y el precio se paga en el camino feliz:

Concepto Efecto
Escritura condicional 1 WCU por mensaje procesado
Actualización del estado final 1 WCU más, si la haces
Borrado por TTL sin coste de escritura
Almacenamiento proporcional a la ventana de TTL

Es decir: añades una o dos escrituras a DynamoDB por cada mensaje, incluso cuando no hay ningún duplicado — que es el 99,9 % de las veces. A cambio evitas el 0,1 % que te cobra dos veces a un cliente. Casi siempre es un cambio excelente, pero conviene saber que se está haciendo.

El borrado por TTL no consume capacidad de escritura, salvo en tablas globales: ahí la réplica del borrado sí se factura en cada región réplica.

Fuentes

Garantías de entrega y desorden en colas estándar: Amazon SQS standard queues. Retraso real del borrado por TTL, visibilidad de los elementos caducados y consumo de capacidad: Using time to live (TTL) in DynamoDB. Reintentos de entrega de SNS: Amazon SNS message delivery retries.

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é.