Lab 22 — Fila que absorve pico, com DLQ e idempotência
O problema, e a empresa que o tem
A Cadência processa pedidos das suas trinta lojas pela mesma API do L01 e do L03: .NET 8 em ECS Fargate, atrás de um ALB. Em dia normal, meio pedido por segundo — cerca de 43 mil por dia — passa sem drama pela cadeia de gravar o pedido, debitar o estoque e cobrar o cartão, tudo dentro do mesmo request HTTP.
A equipe de marketing anunciou uma campanha de um dia com desconto agressivo em três categorias. Na simulação de tráfego, a vazão prevista é dez vezes a normal por cerca de vinte minutos no pico. No teste, a API começou a devolver 504 depois de três minutos — sem nenhuma linha de erro no log da aplicação, porque as requisições nunca chegaram a ser processadas.
O segundo problema apareceu só depois: comparando o número de cobranças no gateway de pagamento com o número de pedidos gravados no banco, havia mais cobranças do que pedidos. Clientes que receberam timeout tentaram de novo — e a segunda tentativa, sem nenhuma chave que identificasse "isto é o mesmo pedido", cobrou o cartão pela segunda vez.
O que este laboratório NÃO é
Não é sobre integração real com um gateway de pagamento, nem sobre o desenho completo do checkout. O foco é o padrão fila + DLQ + idempotência, que se repete em qualquer sistema que precise desacoplar produtor de consumidor. Fanout para múltiplos consumidores sem tocar no produtor é o L23; retry sofisticado com disjuntor para uma dependência instável é o L36 — os dois dependem deste.
O que você vai conseguir fazer
Objetivos verificáveis: cada um se prova com um comando na seção de implantação, não com a sensação de ter entendido.
- Explicar por que "ao menos uma vez" não é um defeito a corrigir, e sim o contrato da fila.
- Nomear os dois relógios do padrão: visibility timeout e retenção da DLQ, e o que cada um decide.
- Implementar idempotência com escrita condicional no DynamoDB, distinguindo primeira entrega de reentrega.
- Configurar `ReportBatchItemFailures` e explicar o que acontece sem ele num lote de 10 mensagens.
- Dimensionar `maxReceiveCount` e o visibility timeout a partir do timeout real da função.
- Provar, matando o consumidor no meio do processamento, que a reentrega não duplica o efeito.
- Medir a fila absorvendo um pico de 10× sem devolver erro ao cliente.
- Diagnosticar uma DLQ crescendo e decidir se a causa é mensagem malformada ou dependência fora do ar.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| Standard vs FIFO | DVA-C02, SAA-C03 | a escolha de standard, justificada pela independência entre pedidos | quando ordem importa de verdade — e quando parece importar e não importa |
| Visibility timeout | DVA-C02, SAA-C03 | o relógio que evita dois consumidores processando a mesma mensagem ao mesmo tempo | que ele NÃO impede reentrega — só adia; e que 12 h é o teto absoluto |
| Dead-letter queue e redrive policy | DVA-C02, SAA-C03 | `maxReceiveCount` e o destino de quem falha persistentemente | a DLQ sem alarme é tão inútil quanto não ter DLQ |
| Idempotência e at-least-once | DVA-C02 | escrita condicional como o único mecanismo real de "uma vez só" | a fila nunca promete "exatamente uma vez" numa fila standard |
| Partial batch response (`ReportBatchItemFailures`) | DVA-C02 | o que evita reprocessar mensagens já bem-sucedidas dentro de um lote | o comportamento padrão sem essa configuração: o lote inteiro volta |
| Long polling | SAA-C03 | implícito no SDK do Lambda para SQS — reduz `ReceiveMessage` vazio | a diferença de custo e latência frente a short polling |
| Fila como buffer de absorção de pico | SAA-C03, SAP-C02 | o núcleo do laboratório: desacoplar vazão de capacidade de processamento | por que isso resolve PERDA, e não resolve DUPLICATA sozinho |
Onde isto costuma ser cobrado errado
A pergunta clássica dá uma fila standard, um consumidor sem tratamento de erro específico, e pergunta o que garante que a mensagem não seja processada duas vezes. A resposta "nada garante isso — é responsabilidade do consumidor" é a que a prova busca, e é a que mais gente erra por assumir que "at-least-once" é jargão sem consequência prática.
Requisitos, e como cada um muda o desenho
Requisito não funcional que não aparece numa linha de configuração é intenção. A coluna da direita é onde cada um deixou marca.
| Requisito | Valor declarado | O que ele decide no desenho |
|---|---|---|
| Zero pedido perdido durante o pico | obrigatório | retenção de 4 dias na fila principal, muito acima da duração de qualquer campanha |
| Zero cobrança duplicada | obrigatório | tabela DynamoDB com `ConditionExpression: attribute_not_exists`; a fila sozinha não entrega isso |
| Resposta ao cliente sem esperar confirmação de pagamento | aceito pelo negócio | API responde 202 ao enfileirar, não ao processar — muda o contrato da rota |
| Ordem entre pedidos DIFERENTES | não importa | autoriza fila standard em vez de FIFO, com throughput bem maior |
| Falha permanente tem de ser vista por humano | requisito operacional | DLQ com alarme de `ApproximateNumberOfMessagesVisible`, não só a existência da fila |
| Tempo de processamento por pedido | até 10 s no pior caso | timeout da função Lambda; e o visibility timeout da fila (6× esse valor) |
| Falha transitória não deve virar alarme na primeira tentativa | requisito operacional | `maxReceiveCount` de 5, não de 1 — dá chance a instabilidade momentânea do gateway |
Arquitetura mínima: tudo no mesmo request
Este é o desenho que a Cadência tem hoje, e ele é legítimo como ponto de partida: publica de verdade, com poucas peças. O laboratório começa por medir o defeito — porque um número torna a indisponibilidade discutível, e "a API caiu na campanha" não.
- → POST /api/pedidos
- → encaminha ao alvo saudável
- → grava pedido + decrementa estoque
- → cobra o cartão, síncrono, ~700 ms
- → reenvio após timeout, sem chave de pedido
- Fora da AWS
- Rede e entrega
- Compute
- Banco de dados
Este desenho é o que a Cadência tem hoje, e ele publica de verdade: poucas peças, fácil de entender. A indisponibilidade da campanha não vem de nenhum bug — vem de aritmética de concorrência que ninguém fez. Percorra os passos e repare que o segundo defeito, a cobrança duplicada, nasce do cliente reagindo ao primeiro.
- Três operações, um request, uma thread presa até o fim. Gravar o pedido, decrementar o estoque e cobrar o cartão acontecem em série, dentro do mesmo handler HTTP. O cliente só recebe resposta quando a TERCEIRA etapa termina — e ela é a mais lenta das três.
- A latência do terceiro é fixa, e o handler fica preso nela. O gateway de pagamento responde em cerca de 700 ms independente de quantos pedidos a Cadência manda por segundo. Não é elástico do lado da Cadência: é uma constante que cada request paga inteira.
- A conta de concorrência: vazão vezes latência. Em dia normal, 0,5 pedido/s × 0,85 s de processamento pede menos de 1 requisição simultânea — qualquer pool serve. Na campanha, 5 pedidos/s × 0,85 s pedem cerca de 4 requisições em voo ao mesmo tempo só para não enfileirar NADA. Um pool dimensionado para o normal não tem essa folga.
- Quando o pool acaba, a fila vira o próprio request do cliente. Sem excedente de conexão nem de thread, cada request novo espera um lugar livre. Essa espera conta para o timeout do ALB — e quando ele estoura, o cliente recebe 504 mesmo com a aplicação viva e sem erro nenhum no log dela.
- O cliente reage ao 504 do jeito mais óbvio: tenta de novo. Sem nenhuma chave que identifique "este é o mesmo pedido de antes", o segundo POST é, para a API, um pedido novo. Se o primeiro tinha efetivamente sido cobrado — só o 200 de resposta que se perdeu no timeout —, o cliente paga duas vezes.
- Por que a Cadência publicou assim. Porque funcionou nos testes, com um usuário por vez. O defeito só existe sob concorrência, e concorrência é exatamente o que um teste manual não produz.
Os dois defeitos não são o mesmo, e um produz o outro
O primeiro é indisponibilidade: sem excedente de concorrência, o pool satura e o ALB devolve 504 sem que a aplicação registre erro nenhum. O segundo é cobrança duplicada: o cliente que recebeu 504 reenvia o pedido, e sem chave de deduplicação a API trata isso como um pedido novo. O segundo é consequência direta do primeiro — resolver só a indisponibilidade sem idempotência deixaria a cobrança duplicada acontecer de outro jeito, na primeira instabilidade seguinte.
A prova de que isso é aritmética, não falha pontual: rode a carga de campanha contra este desenho antes de mudar qualquer coisa.
#!/usr/bin/env bash
# carga.sh — reproduz o pico de campanha contra as duas arquiteturas
set -euo pipefail
URL="https://$(terraform output -raw dominio)/api/pedidos"
TOTAL=3000 # 10x o normal por 10 minutos, comprimido para o laboratorio
CONCORRENCIA=20
# Cada requisicao manda um item e quantidade fixos, SEM chave de deduplicacao —
# e assim que o cliente real se comporta na versao sincrona.
seq 1 "$TOTAL" | xargs -P "$CONCORRENCIA" -I{} \
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$URL" \
-H 'Content-Type: application/json' \
-d '{"itemId":"sku-42","quantidade":1,"valorCentavos":15000}' \
>> resultado_carga.txt
echo "200/202: $(grep -cE '^20[02]$' resultado_carga.txt)"
echo "5xx: $(grep -cE '^5' resultado_carga.txt)"
echo "total: $(wc -l < resultado_carga.txt)"
# Na arquitetura sincrona (medicao de exemplo): 3.000 requisicoes, 640 com
# 502/504 (21%) — o pool de conexao satura antes do fim do laco.
# Na arquitetura com fila: 3.000 requisicoes, 3.000 com 202 — a fila absorve
# o excesso; o que muda e quanto tempo o CONSUMIDOR leva para esvaziar.
Arquitetura para produção: a fila absorve, a condição decide
Cada peça nova abaixo rastreia a uma linha da tabela de requisitos. Se você não conseguir apontar o requisito, a peça é adorno — e este desenho não tem nenhuma.
- → POST /api/pedidos
- → encaminha ao alvo saudável
- → SendMessage: pedido serializado + PedidoId
- → poll em lote, até 10 mensagens
- → PutItem condicional: attribute_not_exists(PedidoId)
- → cobra o cartão — só se a condição foi nova
- → ReportBatchItemFailures: devolve só o item que falhou
- → após 5 tentativas, redrive policy
- → ApproximateNumberOfMessagesVisible > 0
- Fora da AWS
- Rede e entrega
- Compute
- Integração de apps
- Banco de dados
- Gestão e governança
A mudança estrutural não é "adicionar uma fila": é que o trabalho pesado sai do request HTTP e vira um consumidor que escala no próprio ritmo. A fila absorve o pico; a condição de escrita no DynamoDB é quem garante que o at-least-once da fila não vira duplicata. São dois mecanismos diferentes para dois problemas diferentes — percorra os passos para separá-los.
- A API para de processar — ela só registra a intenção. O handler grava o pedido na fila e devolve 202 em milissegundos. Cobrar o cartão e debitar o estoque deixam de bloquear o cliente: viram trabalho de outro processo, em outro momento.
- A fila é o buffer explícito que a versão síncrona não tinha. Antes, o "buffer" era implícito: o pool de conexões do handler, que estourava sob carga. Agora ele é uma fila com retenção de até 4 dias — o pico da campanha para de ser um problema de capacidade da API e vira um problema de quanto tempo o consumidor leva para esvaziar a fila.
- O consumidor processa em lote, não mensagem a mensagem. O Lambda recebe até 10 mensagens por invocação e escala o número de invocações concorrentes pelo tamanho do backlog — sozinho, sem ninguém provisionar capacidade para o pico com antecedência.
- A condição de escrita é o mecanismo de idempotência — não a fila. SQS garante "ao menos uma vez", nunca "exatamente uma vez", para fila standard. O `PutItem` com `ConditionExpression: attribute_not_exists(PedidoId)` só tem sucesso na PRIMEIRA vez que aquele pedido é visto; nas seguintes, falha de propósito.
- O efeito de negócio só roda quando a condição é nova. Cobrar o cartão acontece DEPOIS do `PutItem` ter sucesso, nunca antes. Se a condição falhar — pedido já visto —, a função pula direto para "sucesso" sem chamar o gateway de novo. É essa ordem, e não a fila, que impede a cobrança dupla.
- Falha parcial devolve só quem falhou. Com `ReportBatchItemFailures`, se 1 das 10 mensagens do lote falhar, só ela volta para a fila. Sem essa configuração, o comportamento padrão do Lambda é devolver as 10 — inclusive as 9 que já tiveram efeito aplicado, e cujo `PutItem` vai falhar na reentrega (o que é inofensivo, mas desperdiça invocação e mascara o item que de fato precisa de atenção).
- Depois de 5 tentativas, a mensagem muda de fila — e alguém tem de olhar. A redrive policy move a mensagem para a DLQ após `maxReceiveCount` falhas. Sem o alarme de `ApproximateNumberOfMessagesVisible`, uma mensagem pode ficar até 14 dias ali sem que ninguém saiba que um pedido parou de ser processado.
A diferença estrutural em relação ao desenho anterior não é "adicionar uma fila entre a API e o banco": é que o trabalho pesado sai do request HTTP por completo. A API deixa de saber o que aconteceu com o pedido depois do `SendMessage` — quem sabe é o consumidor, minutos depois.
Os dois problemas, resolvidos por dois mecanismos diferentes
A fila resolve PERDA: nada desaparece enquanto a retenção não expirar, e o consumidor pode estar mais lento que a chegada sem que isso vire erro para o cliente. A condição de escrita no DynamoDB resolve DUPLICATA: é ela, e só ela, que transforma "a mesma mensagem chegou de novo" em "nada aconteceu da segunda vez". Tratar os dois como o mesmo problema é o erro mais caro desta arquitetura.
O caminho de um pedido, ponta a ponta
Os nomes dos estados não são jargão: eles são o que separa "a mensagem existe" de "o efeito aconteceu". Isso é observável com describe-tasks trocado por get-queue-attributes e dynamodb query durante o teste — e a seção de provas faz exatamente isso.
O corpo que a API grava na fila. PedidoId e gerado no PRODUTOR, nao no consumidor — e e ele que vira a chave de particao da tabela de idempotencia.
{
"pedidoId": "b3f1c2a4-7e21-4c9a-9f0e-1a2b3c4d5e6f",
"itemId": "sku-42",
"quantidade": 1,
"valorCentavos": 15000,
"lojaId": "loja-17",
"recebidoEm": "2026-08-07T14:22:31.618Z"
}Por que o PedidoId nasce na API, não no consumidor
Se o consumidor gerasse a chave de idempotência a partir do conteúdo da mensagem, duas mensagens com o mesmo pedido mas geradas por dois cliques do usuário (sem timeout no meio) teriam IDs diferentes e seriam processadas como pedidos distintos — o problema voltaria pela porta de trás. A chave tem de vir de uma decisão explícita do PRODUTOR sobre o que conta como "o mesmo pedido", não de um cálculo do consumidor sobre o conteúdo.
As decisões, e o que se perde em cada uma
📋 Absorver um pico de campanha de até 10× o tráfego normal numa API de pedidos, sem perder pedido, sem cobrar duas vezes, e sem que o cliente espere o pagamento ser confirmado para receber uma resposta.
O problema tem duas partes que pedem mecanismos diferentes, e a fila standard resolve exatamente a parte que é dela: absorver um volume que varia sem que o produtor (a API) precise ser dimensionado para o pico. A ordem entre PEDIDOS DIFERENTES não importa — cada um é independente —, então FIFO não compra nada aqui e custaria throughput. O que resolve a duplicata não é a fila: é a condição de escrita no DynamoDB, e ela funciona igual com standard ou FIFO. Escolher standard é a decisão mais barata que ainda atende o requisito real.
Alt: Fila FIFO — Garante ordem e deduplicação por `MessageDeduplicationId`, mas o teto de throughput é bem menor e o batch size do Lambda cai para 10 fixos. Compensa quando a ORDEM entre mensagens importa — pedidos independentes não é esse caso.
Alt: Retry com backoff no próprio handler síncrono (sem fila) — Reduz timeout, mas não resolve o problema real: a capacidade da API continua dimensionada para o pico, e o pico só existe em campanha. É a solução do L36 para um problema diferente — instabilidade de uma dependência, não excesso de volume.
Alt: Kinesis Data Streams — Feito para volume ordenado e consumo por múltiplos leitores independentes com replay. Para "processar um pedido uma vez, com DLQ pronta", é mais operação (shards, checkpoint) para resolver um problema que SQS resolve com menos peças.
Alt: Step Functions orquestrando o fluxo — Vale quando o fluxo tem VÁRIAS etapas com compensação entre si (saga) — é o L25. Aqui há uma etapa de negócio (cobrar e debitar), e orquestrar isso é peso sem função.
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Tipo de fila | Standard | FIFO com `MessageGroupId` por cliente | pedidos independentes não precisam de ordem; standard tem throughput bem maior | nenhuma garantia de ordem entre pedidos — irrelevante para este requisito |
| Mecanismo de idempotência | DynamoDB com `ConditionExpression` | deduplicação nativa de fila FIFO; cache com TTL curto | condição de escrita é atômica e não expira antes da hora — dedup de FIFO cobre só 5 min | mais uma tabela para manter, e um TTL para decidir |
| Reporte de falha | `ReportBatchItemFailures` por mensagem | lote pequeno (1) para simplificar; sem partial batch, aceitando reprocesso total | evita reprocessar quem já teve sucesso, sem abrir mão do throughput de lote | mais código no laço de processamento — cada item precisa do próprio try/catch |
| `maxReceiveCount` | 5 | 1 (falha na primeira tentativa já vai para DLQ); 100 (quase nunca desiste) | dá margem a falha transitória sem deixar mensagem ruim reprocessando indefinidamente | um pedido com problema real leva 5 ciclos de visibility timeout até virar alarme |
| Visibility timeout | 60 s (6× o timeout da função) | 10 s (igual ao timeout); 300 s (bem folgado) | segue a recomendação da AWS: cobre reentrega por throttling sem multiplicar o atraso | nada aqui — é o ponto de equilíbrio documentado |
Construir: a API para de processar, e passa a registrar intenção
A mudança na API é pequena em linhas e grande em contrato: o endpoint deixa de devolver "o pedido confirmado" e passa a devolver "recebi a intenção". Quem consome essa API precisa saber disso — é uma mudança de contrato, não um detalhe de implementação.
// Program.cs (ANTES) — tudo dentro do mesmo request
app.MapPost("/api/pedidos", async (NovoPedidoRequest req, AppDb db, IGatewayPagamento gateway) =>
{
// TRES operacoes, em serie, no MESMO request: gravar, decrementar estoque,
// cobrar. O cliente so recebe resposta quando a ULTIMA termina — e ela e a
// mais lenta das tres, porque depende de um servico que a Cadencia nao controla.
var pedido = new Pedido(req.ItemId, req.Quantidade, req.ValorCentavos);
db.Pedidos.Add(pedido);
db.Estoque.Decrementar(req.ItemId, req.Quantidade);
await db.SaveChangesAsync();
// ~700 ms no p50, sem retry, sem timeout configurado. Sob carga normal
// (0,5 pedido/s) isso e invisivel. Sob 10x, e o gargalo inteiro.
await gateway.CobrarAsync(pedido.Id, req.ValorCentavos);
return Results.Ok(pedido);
});
// Program.cs (DEPOIS) — a API registra a intencao e responde
app.MapPost("/api/pedidos", async (NovoPedidoRequest req, IAmazonSQS sqs, IOptions<FilaConfig> cfg) =>
{
var pedidoId = Guid.NewGuid().ToString();
var corpo = JsonSerializer.Serialize(new PedidoRecebido(
PedidoId: pedidoId,
ItemId: req.ItemId,
Quantidade: req.Quantidade,
ValorCentavos: req.ValorCentavos));
// A API so registra a INTENCAO. Nenhum estoque e decrementado e nenhum
// gateway e chamado aqui — isso passou a ser trabalho do consumidor,
// minutos ou so segundos depois, no seu proprio ritmo.
await sqs.SendMessageAsync(new SendMessageRequest
{
QueueUrl = cfg.Value.FilaPedidosUrl,
MessageBody = corpo,
MessageAttributes = new Dictionary<string, MessageAttributeValue>
{
["PedidoId"] = new() { DataType = "String", StringValue = pedidoId },
},
});
// 202: "recebi a intencao", nao "processei o pedido". E a mudanca de
// contrato que o cliente da API precisa saber — nao e mais um 200 com o
// pedido confirmado.
return Results.Accepted(value: new { pedidoId, status = "recebido" });
});
O `SendMessageAsync` também pode falhar
Se a chamada ao SQS lançar exceção, a API precisa decidir o que fazer — normalmente devolver 5xx e deixar o cliente tentar de novo, já que nada foi registrado. Ignorar essa exceção com um `try/catch` vazio produz o pior cenário: o cliente recebe 202 achando que o pedido foi aceito, e ele nunca existiu em lugar nenhum.
Construir: a fila principal, a DLQ e o alarme
Os dois relógios deste laboratório vivem em objetos diferentes — visibility timeout na fila, timeout na função — e é por isso que quase nunca são lidos juntos. O comentário no Terraform amarra um ao outro.
# fila.tf — a fila principal e a DLQ, com os dois relogios explicitos
resource "aws_sqs_queue" "pedidos_dlq" {
name = "${var.projeto}-pedidos-dlq"
# Retencao MAXIMA do servico: 14 dias. Quem for investigar tem duas semanas,
# nao os 4 dias padrao da fila principal. Mensagem que morre aqui sem alguem
# olhar e o "lixo silencioso" que o alarme abaixo existe para evitar.
message_retention_seconds = 1209600
sqs_managed_sse_enabled = true
}
resource "aws_sqs_queue" "pedidos" {
name = "${var.projeto}-pedidos"
# RELOGIO 1: o teto de paciencia antes de a mensagem reaparecer para outro
# consumidor. A recomendacao da AWS e pelo menos 6x o timeout da funcao —
# aqui a funcao tem 10 s, entao o minimo seguro e 60 s. Abaixo disso, uma
# reentrega pode colidir com o PROPRIO processamento ainda em andamento.
visibility_timeout_seconds = 60
# Quanto tempo uma mensagem nao consumida sobrevive antes de ser descartada.
# O padrao (4 dias) cobre qualquer pico de campanha real; se o consumidor
# ficar parado por mais que isso, o problema deixou de ser "fila".
message_retention_seconds = 345600
sqs_managed_sse_enabled = true
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.pedidos_dlq.arn
# A AWS recomenda pelo menos 5: da ao consumidor algumas chances de
# reentrega antes de desistir. Falha transitoria (o gateway de pagamento
# cai por um instante) nao deveria virar alarme na PRIMEIRA tentativa.
maxReceiveCount = 5
})
}
# So a fila principal pode redirecionar para esta DLQ — nao qualquer fila da
# conta. Sem isto, qualquer engenheiro com permissao de SQS poderia apontar
# uma fila nao relacionada para a mesma DLQ, misturando causas de falha.
resource "aws_sqs_queue_redrive_allow_policy" "dlq_allow" {
queue_url = aws_sqs_queue.pedidos_dlq.id
redrive_allow_policy = jsonencode({
redrivePermission = "byQueue"
sourceQueueArns = [aws_sqs_queue.pedidos.arn]
})
}
# O alarme e a razao de a DLQ nao ser lixo silencioso. threshold = 0 significa
# "qualquer mensagem visivel ja dispara" — nao existe volume aceitavel de
# pedido que parou de ser processado.
resource "aws_cloudwatch_metric_alarm" "dlq_com_mensagem" {
alarm_name = "${var.projeto}-pedidos-dlq-nao-vazia"
namespace = "AWS/SQS"
metric_name = "ApproximateNumberOfMessagesVisible"
statistic = "Maximum"
period = 300
evaluation_periods = 1
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
dimensions = {
QueueName = aws_sqs_queue.pedidos_dlq.name
}
alarm_actions = [aws_sns_topic.alertas.arn]
}
output "fila_pedidos_url" {
value = aws_sqs_queue.pedidos.id
description = "URL da fila principal; e para aqui que a API publica"
}
DLQ sem alarme é a mesma coisa que não ter DLQ
A dead-letter queue existe para dar visibilidade a falha permanente — mas ela é passiva: nada nela dispara notificação por conta própria. Sem o `aws_cloudwatch_metric_alarm` acima, um pedido pode ficar até 14 dias parado ali, e a primeira pessoa a descobrir é o cliente reclamando que o pedido nunca chegou.
Construir: o Lambda, o event source mapping e as permissões
O `function_response_types = ["ReportBatchItemFailures"]` é a única linha que decide se uma falha isolada custa um item ou um lote inteiro. É fácil de esquecer porque, sem ela, tudo parece funcionar — o lote é reprocessado, e reprocessar não gera erro visível, só desperdício e confusão sobre o que de fato falhou.
# consumidor.tf — o Lambda, o event source mapping e as permissoes minimas
resource "aws_lambda_function" "consumidor" {
function_name = "${var.projeto}-consumidor-pedidos"
runtime = "dotnet8"
handler = "Consumidor::Cadencia.Pedidos.Consumidor.Function::FunctionHandler"
role = aws_iam_role.consumidor.arn
# RELOGIO 2: o timeout da funcao. O visibility_timeout de 60 s da fila (em
# fila.tf) tem de ser pelo menos 6x este valor — e 6 x 10 = 60, sem folga
# sobrando. Subir este numero sem revisar o outro quebra a relacao.
timeout = 10
memory_size = 512
filename = data.archive_file.consumidor.output_path
source_code_hash = data.archive_file.consumidor.output_base64sha256
environment {
variables = {
TABELA_IDEMPOTENCIA = aws_dynamodb_table.pedidos_processados.name
}
}
}
resource "aws_lambda_event_source_mapping" "consumidor_pedidos" {
event_source_arn = aws_sqs_queue.pedidos.arn
function_name = aws_lambda_function.consumidor.arn
batch_size = 10 # o padrao; e tambem o teto de uma fila FIFO. Standard aceita ate 10.000.
# O MECANISMO central deste laboratorio, do lado da configuracao. Sem isto,
# UMA mensagem com erro num lote de 10 devolve as OUTRAS NOVE para a fila
# tambem — inclusive as que ja tiveram efeito aplicado. Com isto, a funcao
# devolve so os IDs que de fato falharam.
function_response_types = ["ReportBatchItemFailures"]
maximum_batching_window_in_seconds = 5
}
data "aws_iam_policy_document" "consumidor_permissoes" {
statement {
effect = "Allow"
actions = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"]
# So a fila PRINCIPAL. O consumidor nunca le da DLQ — quem le a DLQ e um
# humano investigando, nao o codigo de producao.
resources = [aws_sqs_queue.pedidos.arn]
}
statement {
effect = "Allow"
actions = ["dynamodb:PutItem"]
resources = [aws_dynamodb_table.pedidos_processados.arn]
}
}
resource "aws_iam_role" "consumidor" {
name = "${var.projeto}-consumidor-pedidos"
assume_role_policy = data.aws_iam_policy_document.consumidor_assume.json
}
resource "aws_iam_role_policy" "consumidor_permissoes" {
role = aws_iam_role.consumidor.id
policy = data.aws_iam_policy_document.consumidor_permissoes.json
}
resource "aws_iam_role_policy_attachment" "consumidor_logs" {
role = aws_iam_role.consumidor.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
| Onde | Parâmetro | Recomendação da AWS | Aqui | Por quê |
|---|---|---|---|---|
| Fila | `visibility_timeout_seconds` | ≥ 6× o timeout da função | 60 s | 6 × 10 s = 60 s, sem folga sobrando — qualquer aumento no timeout da função exige revisar aqui |
| Fila | `maxReceiveCount` | ≥ 5 | 5 | dá margem a falha transitória do gateway sem deixar mensagem ruim reprocessando sem fim |
| Lambda | `timeout` | menor que o visibility timeout | 10 s | cobre a escrita condicional (rápida) mais a chamada ao gateway (~700-800 ms) com folga larga |
| Event source mapping | `batch_size` | até 10.000 (standard) | 10 | o padrão; lote maior exige `maximum_batching_window_in_seconds` ≥ 1 s |
Construir: a tabela de idempotência, e o consumidor que a usa
A tabela existe para uma coisa só: guardar "este PedidoId já foi visto" com uma escrita que só tem sucesso uma vez. Tudo o mais — TTL, `point_in_time_recovery` — é cuidado em volta desse único fato.
# idempotencia.tf — a tabela onde a duplicata vira nao-operacao
resource "aws_dynamodb_table" "pedidos_processados" {
name = "${var.projeto}-pedidos-processados"
billing_mode = "PAY_PER_REQUEST" # o trafego e o mesmo pico irregular da fila; provisionado sobraria ocioso
hash_key = "PedidoId"
attribute {
name = "PedidoId"
type = "S"
}
# A marca de idempotencia nao precisa viver para sempre. 30 dias cobre
# qualquer reentrega tardia plausivel — a retencao MAXIMA da propria fila e
# 14 dias — sem acumular item indefinidamente.
ttl {
attribute_name = "ExpiraEm"
enabled = true
}
point_in_time_recovery {
enabled = true # e o registro de "isto ja foi cobrado"; restaurar errado cobra duas vezes
}
}
// Function.cs — consome em lote, decide por mensagem, devolve so quem falhou
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
using Amazon.Lambda.Core;
using Amazon.Lambda.SQSEvents;
using System.Text.Json;
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
namespace Cadencia.Pedidos.Consumidor;
public class Function
{
private static readonly AmazonDynamoDBClient _dynamo = new();
private static readonly HttpClient _gatewayPagamento = new() { Timeout = TimeSpan.FromSeconds(5) };
private static readonly string _tabela = Environment.GetEnvironmentVariable("TABELA_IDEMPOTENCIA")!;
public async Task<SQSBatchResponse> FunctionHandler(SQSEvent evento, ILambdaContext contexto)
{
var falhas = new List<SQSBatchResponse.BatchItemFailure>();
// Cada mensagem do lote e tratada de forma INDEPENDENTE: uma excecao
// aqui nao pode impedir que as outras sejam reportadas como sucesso —
// e para isso que existe o ReportBatchItemFailures no event source
// mapping. Sem essa configuracao (e sem este try/catch por mensagem),
// uma unica falha devolveria o LOTE INTEIRO para a fila.
foreach (var mensagem in evento.Records)
{
try
{
await ProcessarPedidoAsync(mensagem, contexto);
}
catch (Exception ex)
{
contexto.Logger.LogError($"Falha ao processar {mensagem.MessageId}: {ex.Message}");
falhas.Add(new SQSBatchResponse.BatchItemFailure { ItemIdentifier = mensagem.MessageId });
}
}
return new SQSBatchResponse(falhas);
}
private async Task ProcessarPedidoAsync(SQSEvent.SQSMessage mensagem, ILambdaContext contexto)
{
var pedido = JsonSerializer.Deserialize<PedidoRecebido>(mensagem.Body)!;
// O MECANISMO de idempotencia mora INTEIRO nesta escrita condicional.
// attribute_not_exists(PedidoId) so deixa o PutItem ter sucesso se
// este for o PRIMEIRO PutItem com essa chave. SQS pode entregar a
// mesma mensagem mais de uma vez (at-least-once, nunca exactly-once
// numa fila standard); esta condicao e o que garante que o EFEITO de
// negocio acontece uma vez so, nao a fila.
try
{
await _dynamo.PutItemAsync(new PutItemRequest
{
TableName = _tabela,
Item = new Dictionary<string, AttributeValue>
{
["PedidoId"] = new AttributeValue { S = pedido.PedidoId },
["ProcessadoEm"] = new AttributeValue { S = DateTimeOffset.UtcNow.ToString("O") },
// TTL: a marca de idempotencia nao precisa viver para sempre.
["ExpiraEm"] = new AttributeValue
{ N = DateTimeOffset.UtcNow.AddDays(30).ToUnixTimeSeconds().ToString() },
},
ConditionExpression = "attribute_not_exists(PedidoId)",
});
}
catch (ConditionalCheckFailedException)
{
// Chegou aqui de novo. NAO e um erro de aplicacao: e o contrato
// at-least-once se manifestando. O efeito de negocio ja aconteceu
// na entrega anterior — pular e devolver sucesso e a resposta
// CORRETA, e e exatamente o que evita a cobranca duplicada.
contexto.Logger.LogInformation(
$"Pedido {pedido.PedidoId} ja processado — reentrega ignorada, sem duplicar efeito");
return;
}
// So chega aqui quando o PutItem acima criou o item pela PRIMEIRA vez.
// E aqui, e so aqui, que o efeito de negocio acontece.
await DebitarEstoqueAsync(pedido);
await CobrarCartaoAsync(pedido);
}
private static async Task DebitarEstoqueAsync(PedidoRecebido pedido)
{
// Decremento no PostgreSQL do L01/L03 — fora do escopo deste trecho.
// O ponto deste laboratorio nao e ONDE o estoque mora, e sim QUANDO o
// decremento acontece: depois da condicao de escrita ter passado.
await Task.CompletedTask;
}
private static async Task CobrarCartaoAsync(PedidoRecebido pedido)
{
var resposta = await _gatewayPagamento.PostAsJsonAsync(
"https://gateway.exemplo/cobrancas",
new { pedido.PedidoId, pedido.ValorCentavos });
resposta.EnsureSuccessStatusCode(); // erro aqui vira excecao e o item entra em batchItemFailures
}
}
public record PedidoRecebido(string PedidoId, string ItemId, int Quantidade, int ValorCentavos);
Por que `ConditionalCheckFailedException` não é logada como erro
Ela representa o caminho ESPERADO de uma reentrega, não uma falha do sistema. Logar como erro faria o painel de observabilidade acender toda vez que a fila fizesse exatamente o que o contrato dela promete. O log de informação aqui é o dado que a prova de idempotência da seção seguinte usa.
Implantar, e provar que a duplicata não aconteceu
Cinco provas. Nenhuma delas aceita "a fila parece estar funcionando" como resultado — cada uma tem um número ou um estado esperado, e a terceira é a que mais gente pula por confiar de olhos fechados no "at-least-once resolvido pela AWS".
# provas.sh — cinco medicoes; nenhuma conclusao vem de "parece que funcionou"
PROJETO=ffv-lab-fila
FILA_URL=$(terraform output -raw fila_pedidos_url)
DLQ_URL=$(aws sqs get-queue-url --queue-name "${PROJETO}-pedidos-dlq" --query QueueUrl --output text)
# ── Prova 1: a fila absorve o pico sem devolver erro ao cliente ──────────────
./carga.sh
# Esperado: 100% dos requests com 202. Qualquer 5xx aqui e regressao na API,
# nao na fila — a fila em si nao rejeita escrita por volume dentro dos limites.
# ── Prova 2: o backlog cresce e depois esvazia, sem perder mensagem ──────────
watch -n5 "aws sqs get-queue-attributes --queue-url $FILA_URL \
--attribute-names ApproximateNumberOfMessages ApproximateAgeOfOldestMessage \
--query 'Attributes'"
# Esperado: o numero sobe durante a carga e desce a zero em minutos. Se ele
# NUNCA desce, o consumidor esta mais lento que a chegada — e capacidade do
# Lambda, nao da fila.
# ── Prova 3: a mesma mensagem reentregue nao duplica o efeito ────────────────
# Reduza temporariamente o timeout da funcao Lambda para MENOR que o tempo
# real de processamento, force uma execucao, e mate o processo no meio
# (ou simplesmente reduza o visibility_timeout da fila para poucos segundos).
aws lambda update-function-configuration --function-name "${PROJETO}-consumidor-pedidos" --timeout 1
PEDIDO_ID=$(uuidgen)
aws sqs send-message --queue-url "$FILA_URL" \
--message-body "{\"pedidoId\":\"$PEDIDO_ID\",\"itemId\":\"sku-42\",\"quantidade\":1,\"valorCentavos\":15000}"
sleep 90 # cobre pelo menos duas reentregas com visibility_timeout curto
aws dynamodb query --table-name "${PROJETO}-pedidos-processados" \
--key-condition-expression "PedidoId = :p" \
--expression-attribute-values "{\":p\":{\"S\":\"$PEDIDO_ID\"}}" \
--query "length(Items)"
# Esperado: exatamente 1 item, mesmo com varias tentativas de entrega — e o
# PutItem condicional recusando as tentativas depois da primeira.
aws lambda update-function-configuration --function-name "${PROJETO}-consumidor-pedidos" --timeout 10
# ── Prova 4: falha parcial nao reprocessa quem ja teve sucesso ───────────────
# Envie um lote com 1 mensagem malformada (sem itemId) e 9 validas.
for i in $(seq 1 9); do
aws sqs send-message --queue-url "$FILA_URL" \
--message-body "{\"pedidoId\":\"$(uuidgen)\",\"itemId\":\"sku-42\",\"quantidade\":1,\"valorCentavos\":15000}"
done
aws sqs send-message --queue-url "$FILA_URL" --message-body '{"pedidoId":"quebrado"}'
sleep 15
aws cloudwatch get-metric-statistics --namespace AWS/Lambda --metric-name Invocations \
--dimensions Name=FunctionName,Value="${PROJETO}-consumidor-pedidos" \
--start-time "$(date -u -d '5 min ago' +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
--period 300 --statistics Sum
# Esperado: apenas 1 invocacao adicional na proxima rodada de poll (so a
# mensagem malformada reaparece) — nao 10. Sem ReportBatchItemFailures no
# event source mapping, esperar-se-ia todo o lote de volta.
# ── Prova 5: falha permanente chega na DLQ e dispara o alarme ────────────────
# A mensagem "quebrada" acima falha 5 vezes (deserializacao explode sempre).
sleep 400 # 5 tentativas x 60s de visibility timeout, com folga
aws sqs get-queue-attributes --queue-url "$DLQ_URL" \
--attribute-names ApproximateNumberOfMessages --query 'Attributes.ApproximateNumberOfMessages'
aws cloudwatch describe-alarms --alarm-names "${PROJETO}-pedidos-dlq-nao-vazia" \
--query 'MetricAlarms[0].StateValue'
# Esperado: 1 mensagem na DLQ, alarme em ALARM. Se o numero for 0, o
# maxReceiveCount ainda nao foi atingido — confira ApproximateReceiveCount.
| Prova | Comando | Resultado que aprova | O que reprova, e o que significa |
|---|---|---|---|
| 1 · Absorve o pico | carga de 3.000 pedidos em paralelo | 100% dos requests com 202 | qualquer 5xx aqui é regressão na API, não na fila |
| 2 · Backlog esvazia | `get-queue-attributes` em laço | sobe durante a carga e desce a zero em minutos | se nunca desce, o consumidor está mais lento que a chegada |
| 3 · Reentrega não duplica | força reentrega e conta itens no DynamoDB | exatamente 1 item, mesmo com várias tentativas | mais de 1 item significa que a condição de escrita não está no caminho crítico |
| 4 · Falha parcial não reprocessa sucesso | lote com 1 mensagem ruim + 9 boas | apenas 1 invocação adicional na rodada seguinte | 10 invocações adicionais indica `ReportBatchItemFailures` ausente ou mal configurado |
| 5 · DLQ recebe e alarme dispara | mensagem malformada até esgotar `maxReceiveCount` | 1 mensagem na DLQ, alarme em `ALARM` | DLQ vazia com `ApproximateReceiveCount` alto indica redrive policy não aplicada |
Quebrar de propósito: três falhas e o diagnóstico
As três acontecem de verdade, e a primeira é a mais cara: ela não aparece como erro, aparece como fatura de gateway de pagamento maior que o número de pedidos.
| Falha | Como provocar | Sintoma | Onde olhar | Correção |
|---|---|---|---|---|
| `ReportBatchItemFailures` ausente | remova `function_response_types` do event source mapping | a mesma cobrança acontece 2 vezes quando 1 mensagem do lote falha — mesmo com idempotência | contagem de invocações do Lambda muito acima do número de mensagens que de fato falharam | reative `function_response_types = ["ReportBatchItemFailures"]` |
| Visibility timeout menor que o processamento real | baixe `visibility_timeout_seconds` para 5 com a função ainda levando 8 s | a mesma mensagem é processada por DUAS invocações concorrentes | `ApproximateReceiveCount` sobe mesmo sem erro registrado na função | visibility timeout ≥ 6× o timeout medido da função, nunca o contrário |
| `maxReceiveCount` baixo demais | configure `maxReceiveCount = 1` | pedido legítimo vai para a DLQ na primeira instabilidade momentânea do gateway | DLQ crescendo em correlação com picos de latência do gateway, não com erro de código | suba para 5 e trate a DLQ como sinal de falha PERSISTENTE, não de qualquer falha |
A falha que não aparece em nenhum painel de erro
Sem `ReportBatchItemFailures`, o Lambda continua reportando sucesso ou falha por LOTE, nunca por mensagem — e reprocessar um item que já teve efeito aplicado não gera exceção nenhuma se a idempotência estiver correta. O sintoma real é financeiro: mais chamadas ao gateway de pagamento do que pedidos únicos, e nenhum log vermelho apontando por quê.
Uma fila SQS standard entrega a mesma mensagem duas vezes ao seu consumidor, mesmo sem nenhum erro de configuração. O que isso representa?
Segurança: o que uma fila de pedidos expõe
A fila carrega dado de negócio real — item, valor, loja. Ela não é infraestrutura invisível: é um lugar onde payload sensível fica em repouso, ainda que por minutos.
| Risco | Probabilidade | Impacto | Controle preventivo | Detecção | Resposta |
|---|---|---|---|---|---|
| Mensagem com dado de pagamento em texto plano | média | alto | nunca inclua número de cartão no corpo — só referência tokenizada do gateway | revisão de código; varredura de payload em ambiente de teste | rotacionar token comprometido; nunca há "apagar do log" garantido |
| Consumidor com permissão de escrita em qualquer tabela | baixa | alto | IAM restrito à tabela de idempotência específica, nunca `dynamodb:*` | IAM Access Analyzer sobre uso real do papel | derivar a política do uso medido — é o L41 |
| Poison message reprocessando sem limite | média | médio | `maxReceiveCount` finito com DLQ configurada | alarme de `ApproximateNumberOfMessagesVisible` na DLQ | investigar a mensagem na DLQ antes de decidir reprocessar ou descartar |
| DLQ acessível a qualquer papel da conta | baixa | alto | `redrive_allow_policy` restrita `byQueue`, e leitura da DLQ restrita a papel de investigação | CloudTrail em `ReceiveMessage` na DLQ fora do papel esperado | revogar acesso; auditar o que foi lido |
| Fila sem criptografia em repouso | baixa | médio | `sqs_managed_sse_enabled = true` em ambas as filas | AWS Config regra de fila sem SSE | ativar SSE; não há retroativo para mensagens já entregues e apagadas |
O que este laboratório NÃO cobre
Autenticação de quem pode chamar `POST /api/pedidos` já é resolvida pela camada da API (cookie de sessão sem estado, do Lautenticacao-cognito-sessao-sem-estado). Este módulo assume que a requisição já chegou legítima até a fila.
A DLQ guarda o payload por mais tempo que a fila principal
Catorze dias de retenção na DLQ, contra quatro na fila principal, significam que um pedido com dado de cliente fica em repouso três vezes e meia mais tempo justamente na fila que menos gente audita rotineiramente. Se o payload incluir CPF, endereço ou qualquer dado pessoal, a política de retenção da DLQ entra na mesma discussão de conformidade que a retenção de log — não é só um detalhe operacional.
Observabilidade: as perguntas que o painel tem de responder
| Pergunta | Métrica ou consulta | O que significa mudar | Limiar inicial |
|---|---|---|---|
| A fila está acumulando mais rápido do que esvazia? | `ApproximateNumberOfMessages` | consumidor mais lento que a chegada — capacidade, não perda | crescendo por > 10 min seguidos |
| Algum pedido está esperando demais para ser processado? | `ApproximateAgeOfOldestMessage` | mensagem antiga na fila principal é atraso; na DLQ, é falha permanente | > 300 s na principal |
| Há pedido que não consegue ser processado de jeito nenhum? | `ApproximateNumberOfMessagesVisible` (DLQ) | qualquer valor > 0 é falha persistente, não transitória | > 0 |
| O Lambda está de fato apagando mensagens processadas? | `NumberOfMessagesDeleted` | cair para perto de zero com fila cheia indica função devolvendo tudo como falha | próximo de `NumberOfMessagesReceived` |
| A reentrega está acontecendo mais do que o esperado? | contagem de `ConditionalCheckFailedException` no log | sinal direto de duplicata real acontecendo — é o número que a prova 3 lê | investigar acima de 1% das mensagens |
| O consumidor está sendo limitado (throttled)? | `Throttles` do Lambda | concorrência reservada baixa demais para o tamanho do pico | > 0 durante o pico esperado |
A métrica que engana sozinha
`ApproximateNumberOfMessages` alto durante o pico é ESPERADO — é a fila fazendo o trabalho que ela existe para fazer. O sinal de problema real não é o valor absoluto, é a TENDÊNCIA: se ele não volta a cair depois que a carga cessa, o consumidor tem um problema de capacidade, não a fila.
O par de métricas que confirma que ReportBatchItemFailures está funcionando
`NumberOfMessagesDeleted` caindo para perto de zero enquanto `NumberOfMessagesReceived` continua alto é o sinal mais direto de que a função está devolvendo lotes inteiros como falha em vez de reportar item por item. As duas métricas lidas juntas contam a história que nenhuma das duas conta sozinha.
Escala: 10, 10 mil, 1 milhão, e falha de AZ
| Volume | O que muda | O que passa a doer | O que fazer |
|---|---|---|---|
| 5 pedidos/s (o cenário deste laboratório) | concorrência padrão do Lambda (5 lotes simultâneos) já cobre | nada; é o cenário do laboratório | nada |
| 500 pedidos/s | o Lambda escala até 300 invocações concorrentes a mais por minuto | a rampa de escala do Lambda pode não acompanhar um pico instantâneo | testar o pico real; considerar `Provisioned Mode` no event source mapping se a rampa for insuficiente |
| 5 mil pedidos/s | a tabela de idempotência recebe 5 mil `PutItem`/s | em `PAY_PER_REQUEST` isso é absorvido, mas o custo por escrita passa a ser linha visível | medir custo por escrita; considerar TTL mais curto se o volume de itens crescer rápido |
| 1 milhão de pedidos/dia | nada estrutural muda — SQS, Lambda e DynamoDB são regionais | o gargalo desloca para o que chama o gateway de pagamento, que tem o próprio limite de taxa | é o assunto do L36: retry com backoff e disjuntor para uma dependência com limite próprio |
| Falha de uma AZ | nenhum redesenho é necessário | ao contrário do ALB/ECS dos laboratórios anteriores, SQS, Lambda e DynamoDB já são multi-AZ por padrão, sem configuração extra | nada — é o contraste que vale registrar: serviço regional não tem "plano de falha de AZ" porque a AZ nunca é a unidade de disponibilidade dele |
O contraste com os laboratórios de ECS/RDS
Nos laboratórios anteriores da série, Multi-AZ era uma decisão explícita com custo dobrado. Aqui, os três serviços novos — SQS, Lambda, DynamoDB — são regionais por natureza: a AWS distribui entre AZs sem que isso apareça como parâmetro de Terraform. É uma vantagem real do desenho serverless, e vale nomear por que ela existe, não só usufruir dela.
Custo: o que este laboratório acrescenta à fatura
As três dimensões que cobram são independentes, e o laboratório é barato porque nenhuma delas tem custo fixo — tudo escala com uso real.
| Cenário | Volume | O que acrescenta | Tendência | Otimização |
|---|---|---|---|---|
| Protótipo | 0,5 pedido/s, dia normal | requisições de SQS e invocações de Lambda na casa de dezenas de milhares por mês | desprezível | nenhuma; otimizar aqui é gastar atenção onde não há dinheiro |
| Campanha | pico de 5 pedidos/s por 20 min, algumas vezes por mês | um degrau visível só no dia da campanha; volta ao normal no dia seguinte | baixa e previsível | nenhuma — é exatamente o tipo de carga que serverless existe para não superprovisionar |
| Alta escala | 500 pedidos/s sustentados | linha visível em Lambda (duração × memória) e em leitura/escrita de DynamoDB | crescente e linear | otimizar a duração da função (menos tempo preso no gateway externo) reduz o custo do Lambda direto |
| Dimensão | Cobra por | Cuidado |
|---|---|---|
| SQS | requisições (Send/Receive/Delete), por milhão | long polling do Lambda já reduz `ReceiveMessage` vazio — não é um ajuste manual necessário aqui |
| Lambda | duração × memória alocada, por invocação | a chamada ao gateway de pagamento domina a duração; ela, não o código, é o que cobra mais |
| DynamoDB (PAY_PER_REQUEST) | unidade de leitura/escrita, por operação | o TTL de 30 dias evita a tabela crescer sem teto — sem ele, cada pedido novo é uma linha para sempre |
| CloudWatch | alarmes por mês e ingestão de log | valor pequeno e fixo; não é onde se economiza |
O custo oculto que a arquitetura síncrona tinha, e este não tem
Dimensionar o ECS Fargate da API para suportar 10× o tráfego normal — mesmo que só por 20 minutos, algumas vezes por mês — significa pagar hora ligada de capacidade ociosa 99% do tempo. Com a fila absorvendo o pico, a API volta a ser dimensionada para o tráfego NORMAL, e quem escala para o pico é o Lambda, que só cobra pelas invocações que de fato acontecem.
Well-Architected nos seis pilares
| Pilar | Situação ao fim deste laboratório | Risco que fica | Melhoria | Prioridade |
|---|---|---|---|---|
| Excelência operacional | DLQ com alarme; painel com backlog, idade da mensagem e taxa de reentrega | ninguém definiu o runbook de "o que fazer quando a DLQ tem mensagem" | documentar o playbook de investigação e redrive manual | alta |
| Segurança | IAM restrito por recurso; sem `dynamodb:*` nem `sqs:*` | payload da fila sem criptografia adicional além do SSE gerenciado | avaliar KMS próprio se o payload passar a incluir dado mais sensível | média |
| Confiabilidade | idempotência provada por reentrega forçada; falha parcial isolada por mensagem | a chamada ao gateway de pagamento não tem retry nem timeout configurado no cliente HTTP | Polly com backoff e jitter no `HttpClient` do consumidor (L36) | alta |
| Eficiência de performance | concorrência do Lambda escala automaticamente com o backlog | nenhum teste real de pico acima de 10× foi feito | testar o próximo patamar de carga antes de precisar dele em produção | média |
| Otimização de custos | tudo paga por uso; TTL evita crescimento sem teto na tabela | nenhum | nenhuma ação necessária agora | baixa |
| Sustentabilidade | sem capacidade ociosa provisionada para o pico | nenhum | já é o desenho mais eficiente disponível para esta carga | baixa |
O pilar que este laboratório mais move
Confiabilidade é onde a distância entre o desenho síncrono e este é maior — não porque a fila seja um componente mais robusto, mas porque ela transforma "a API precisa suportar o pico" em "o consumidor precisa, no seu próprio tempo, esvaziar o que a fila acumulou". É uma mudança de que peça carrega o risco, não uma peça a mais.
Evolução em níveis: o que muda, e o que passa a doer
A terceira arquitetura não é um desenho: é a resposta a QUANDO trocar de desenho. Cada nível resolve um risco e compra outro.
Tudo dentro do request HTTP: gravar, debitar, cobrar. É onde a Cadência estava, e continua legítimo para volume baixo e sem pico previsível.Fila SQS como buffer, Lambda consumidor idempotente, DLQ com alarme, `ReportBatchItemFailures` configurado.Fanout com SNS para o mesmo evento de pedido: estoque, notificação, faturamento — cada um com sua própria fila (L23).Retry com backoff exponencial e jitter, disjuntor de circuito no cliente HTTP do gateway de pagamento (L36).Step Functions coordenando reservar estoque, cobrar, confirmar — com compensação explícita se uma etapa falhar depois de outra ter sucesso (L25).Detecção de anomalia sobre o padrão de pedidos e o volume da DLQ — sinalizar fraude ou instabilidade de dependência antes que o alarme simples dispare.A ordem não é negociável, e o motivo é concreto
Fanout (nível 3) sem idempotência (nível 2) multiplica o problema: cada novo assinante reintroduz "processei duas vezes" com sua própria dependência. Orquestração (nível 5) sem retry disciplinado (nível 4) só automatiza a compensação de uma falha que um backoff simples já evitaria. Cada nível resolve o risco que o anterior deixou, não o substitui.
Onde IA entra nesta arquitetura, e onde não entra
Neste módulo, IA não resolve o problema central, e forçá-la seria o antipadrão que a própria série critica. "Como não perder pedido e não cobrar duas vezes" tem resposta determinística: retenção de fila e escrita condicional. Um modelo não melhora nenhuma das duas — são contrato e aritmética.
Há um lugar onde IA acrescentaria valor real, e ele aparece só no nível 6 da evolução: distinguir um pico de campanha legítimo de um padrão anômalo (ataque, bug em loop no cliente gerando pedido repetido). Hoje o alarme de DLQ é um limiar fixo — qualquer mensagem dispara — e isso é correto para "algo falhou permanentemente". Não é a mesma pergunta que "este volume é esperado".
| Pergunta | Resposta honesta para este módulo |
|---|---|
| Qual problema a IA resolveria? | distinguir pico de campanha esperado de padrão anômalo de pedidos |
| Por que uma regra não bastaria? | uma regra de limiar (ex.: "mais de 3× a média móvel") cobre a maior parte dos casos; IA só se justifica quando o padrão sazonal (campanhas recorrentes) torna o limiar fixo ruidoso demais |
| De onde viriam os dados? | histórico de `ApproximateNumberOfMessages` e volume de pedidos por loja, já em CloudWatch |
| Qual o risco? | sinalizar campanha legítima como anomalia e atrasar o processamento de pedidos reais |
| Por que não agora? | a Cadência ainda não tem histórico de campanhas suficiente para treinar nem para avaliar — um modelo sobre poucos eventos é superstição com aparência de estatística |
O uso de IA que parece atraente e é armadilha aqui
Pedir a um modelo para "decidir se o pedido é idempotente" trocaria um mecanismo determinístico — a condição de escrita, que está certa ou está errada, sem meio-termo — por um probabilístico. Idempotência não é uma decisão de julgamento: é uma prova de unicidade. Onde existe mecanismo exato, IA só acrescenta chance de errar com confiança.
Anti-padrões deste laboratório
| Anti-padrão | Por que alguém faz | Por que é problema | Sintoma em produção | Forma correta | Quando é aceitável |
|---|---|---|---|---|---|
| Assumir que a fila entrega exatamente uma vez | o nome "fila confiável" soa como garantia total, e funciona assim em quase todo teste manual | sob volume real, at-least-once se manifesta, e o efeito duplicado só aparece na fatura ou no estoque | mais cobranças no gateway do que pedidos únicos gravados | idempotência explícita com escrita condicional, sempre | nunca — mesmo volume baixo eventualmente entrega duas vezes |
| Ignorar `ReportBatchItemFailures` | o padrão "funciona sem configurar nada extra" parece suficiente em teste com lotes de 1 mensagem | sob lote real de 10, uma falha isolada reprocessa as nove que já tiveram sucesso | contagem de invocações do Lambda muito maior que o número de falhas reais | `function_response_types = ["ReportBatchItemFailures"]` mais o try/catch por mensagem | nunca em produção com batch size > 1 |
| `maxReceiveCount = 1` | parece "mais rigoroso" e manda para a DLQ na primeira suspeita de problema | falha transitória do gateway (instabilidade de alguns segundos) manda pedido legítimo para a DLQ | DLQ crescendo em correlação com picos de latência de uma dependência, não com bug de código | `maxReceiveCount` de pelo menos 5, seguindo a recomendação da AWS | quando o processamento é conhecidamente não-idempotente e reentrega é pior que perder |
| Idempotência "por comentário" | o código tem um comentário dizendo "não deveria processar duas vezes" sem mecanismo real | comentário não impede nada; é decoração que passa despercebida em revisão apressada | duplicata aparece meses depois, na primeira reentrega real sob carga | mecanismo verificável: escrita condicional que FALHA de propósito na segunda tentativa | nunca — comentário não é controle |
| DLQ sem alarme | a DLQ "existe", então parece que a falha permanente já está tratada | nada nela dispara notificação sozinho; ela é um lugar, não um processo | pedido nunca processado e ninguém sabe até o cliente reclamar | alarme de `ApproximateNumberOfMessagesVisible` com threshold em qualquer valor acima de zero | nunca — DLQ sem monitoração é pior que não ter DLQ, porque passa segurança falsa |
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Onde olhar | Correção |
|---|---|---|---|---|
| Mensagens acumulando sem parar de crescer | consumidor mais lento que a chegada, ou throttling | compare `ApproximateNumberOfMessages` com `Throttles` do Lambda | painel de CloudWatch da fila e da função | aumentar concorrência reservada, ou investigar por que cada invocação demora mais que o esperado |
| Mesma cobrança duas vezes, mesmo com idempotência implementada | a chamada ao gateway acontece ANTES do `PutItem` condicional, não depois | releia a ordem do código: escrita condicional tem de vir primeiro, sempre | diff do `ProcessarPedidoAsync` contra a versão de referência | mover a chamada de negócio para depois do `PutItem` bem-sucedido |
| DLQ crescendo em rajadas correlacionadas com horário de pico | falha transitória do gateway de pagamento sob carga, não bug de código | compare o horário das mensagens na DLQ com a latência p99 do gateway | log de erro do `CobrarCartaoAsync` e métrica de latência do terceiro | não é `maxReceiveCount`: é resiliência do cliente HTTP — retry com backoff, é o L36 |
| Lote inteiro reprocessado quando só 1 mensagem falha | `function_response_types` sem `ReportBatchItemFailures`, ou resposta malformada | confira o event source mapping e o formato exato do retorno da função | `aws lambda get-event-source-mapping` e o corpo de retorno de `FunctionHandler` | ative a configuração e confirme que o retorno usa `itemIdentifier`, não outro nome de campo |
| Alarme de DLQ nunca dispara mesmo com mensagem visível | dimensão errada no alarme, ou ele aponta para a fila principal em vez da DLQ | confira `QueueName` nas `dimensions` do alarme contra o nome real da DLQ | `aws cloudwatch describe-alarms` | corrigir a dimensão; testar forçando uma mensagem até a DLQ de propósito |
A pergunta que resolve metade destes casos
Antes de mexer em parâmetro, pergunte: o problema é PERDA (mensagem sumiu), DUPLICATA (efeito aconteceu duas vezes) ou ATRASO (mensagem demorou, mas chegou uma vez)? Cada um aponta para um mecanismo diferente — retenção, condição de escrita, ou capacidade do consumidor — e confundi-los é o que faz alguém ajustar o `maxReceiveCount` para um problema de idempotência.
Limpeza: o que o destroy não leva
Este laboratório é barato de manter, mas dois pontos merecem atenção antes de considerar encerrado: mensagens presas na DLQ e itens acumulados na tabela de idempotência sem TTL ativo por engano.
# 1. Confirme que nao ha mensagem presa antes de derrubar a fila.
aws sqs get-queue-attributes --queue-url "$FILA_URL" \
--attribute-names ApproximateNumberOfMessages --query "Attributes"
aws sqs get-queue-attributes --queue-url "$DLQ_URL" \
--attribute-names ApproximateNumberOfMessages --query "Attributes"
# 2. Derrube o que o Terraform administra: filas, Lambda, event source
# mapping, tabela DynamoDB, alarme.
terraform destroy -auto-approve
# 3. LOG GROUP DO LAMBDA: o Terraform so remove se voce declarou o
# aws_cloudwatch_log_group explicitamente. Sem isso, ele sobrevive
# e continua cobrando por retencao.
aws logs describe-log-groups \
--log-group-name-prefix "/aws/lambda/ffv-lab-fila-consumidor-pedidos" \
--query "logGroups[].logGroupName" --output table
aws logs delete-log-group \
--log-group-name "/aws/lambda/ffv-lab-fila-consumidor-pedidos" 2>/dev/null || true
# 4. Prova final: nada com o nome do projeto de pe.
aws resourcegroupstaggingapi get-resources \
--tag-filters Key=Projeto,Values=ffv-lab-fila \
--query "ResourceTagMappingList[].ResourceARN" --output table| Recurso | Sai no destroy? | Cobra parado? | Por que fica |
|---|---|---|---|
| Fila principal e DLQ | sim | não | não há taxa de existência — só de uso |
| Função Lambda e event source mapping | sim | não | nenhum custo de capacidade reservada por padrão |
| Tabela DynamoDB | sim | não em `PAY_PER_REQUEST` | sem provisionamento fixo, não há custo ocioso |
| Grupo de logs do Lambda | só se declarado em Terraform | sim, retenção | tem ciclo próprio; sobrevive à função que o alimentava se não for gerenciado explicitamente |
| Alarme CloudWatch | sim se em Terraform | centavos | alarme criado à mão no console não aparece no estado |
| Itens na tabela com TTL ainda não vencido | sim, junto com a tabela | não após o destroy | o TTL só importa enquanto a tabela existir; ela some inteira no destroy |
Resumo: problema, peça e motivo
| Problema | Peça | Por que ela, e não outra |
|---|---|---|
| Pico de 10× derruba a API síncrona | fila SQS standard como buffer | desacopla vazão de capacidade — o consumidor escala no próprio ritmo |
| Mesma mensagem entregue mais de uma vez | escrita condicional no DynamoDB | é o único mecanismo que transforma at-least-once em efeito único — a fila não faz isso sozinha |
| Falha isolada reprocessa o lote inteiro | `ReportBatchItemFailures` | devolve só o item que falhou, preservando o efeito das mensagens já bem-sucedidas |
| Falha persistente vira lixo silencioso | DLQ com alarme de profundidade | DLQ sem monitoração dá segurança falsa; o alarme é a parte que faz ela servir para algo |
| Reentrega colide com processamento em andamento | visibility timeout ≥ 6× o timeout da função | segue a recomendação da AWS para cobrir throttling sem multiplicar o atraso |
| Falha transitória vira alarme prematuro | `maxReceiveCount` de 5 | dá margem à instabilidade momentânea de uma dependência externa |
| Falha | O que a protege | O que ela NÃO protege |
|---|---|---|
| Pico de tráfego derrubando a API | fila como buffer explícito | consumidor genuinamente subdimensionado para o volume total — isso é escala do Lambda |
| Cobrança duplicada por reentrega | condição de escrita no DynamoDB | efeito de negócio chamado ANTES da condição — ordem errada no código não é protegida por config nenhuma |
| Reprocessamento de mensagem já bem-sucedida | `ReportBatchItemFailures` | a idempotência em si — sem ela, cada reentrega ainda duplicaria o efeito |
| Mensagem perdida silenciosamente | retenção de 4 dias na fila principal | consumidor parado por mais tempo que a retenção — vira perda de verdade |
| Falha permanente não percebida | alarme de DLQ | decidir o que fazer com a mensagem na DLQ — isso ainda é trabalho humano |
- A API valida o payload e chama `SendMessage`, sem tocar estoque ou gateway de pagamento.
- A resposta 202 sai antes de qualquer efeito de negócio acontecer.
- A mensagem fica invisível na fila pelo tempo do visibility timeout enquanto está sendo processada.
- O Lambda faz `PutItem` condicional: sucesso significa primeira vez; exceção significa reentrega.
- Se for a primeira vez, o efeito de negócio roda — debita estoque, cobra o cartão.
- Se for reentrega, a função encerra sem repetir o efeito, e reporta sucesso mesmo assim.
- Falha real no processamento entra em `batchItemFailures`, e só aquele item volta para a fila.
- Depois de 5 tentativas, a mensagem migra para a DLQ.
- O alarme de `ApproximateNumberOfMessagesVisible` na DLQ avisa um humano.
- A prova de carga confirma 100% de 202 mesmo em 10× o tráfego normal.
Desafio — sem roteiro
O requisito
Envie uma mensagem propositalmente malformada (JSON quebrado ou faltando um campo obrigatório) para a fila e prove que o restante do processamento não trava por causa dela.
Critério de aceite — executável, não "verifique se funciona"
Depois de N tentativas (o `maxReceiveCount` configurado), a mensagem malformada aparece na fila DLQ — e mensagens válidas enviadas DEPOIS dela continuam sendo processadas normalmente, sem fila parada.
- Dica 1: A redrive policy da fila principal aponta para a DLQ com um `maxReceiveCount` — sem essa configuração, a mensagem quebrada fica reciclando para sempre e nunca sai da fila principal.
- Dica 2: O consumidor precisa deixar a mensagem malformada FALHAR explicitamente (não capturar a exceção e silenciosamente descartar) — é a falha que aciona o contador de recebimento da fila.
- Dica 3: Prova de que o resto não travou: SQS padrão processa mensagens em paralelo por natureza — mas se o seu consumidor processa uma de cada vez em loop síncrono, uma mensagem travada ANTES da malformada bloqueia sim. Verifique a ordem do teste.
Lembrete de limpeza
Todo recurso que este desafio criar entra no mesmo `terraform destroy` do laboratório — nada fica para trás cobrando sozinho.
Perguntas frequentes
❓ SQS garante que uma mensagem não será processada duas vezes?
❓ O que acontece se o processamento da mensagem demorar mais que o visibility timeout?
❓ Preciso de fila FIFO para garantir que os pedidos não se dupliquem?
❓ Por que a mensagem some da resposta da minha função sem eu ter deletado nada?
❓ O que muda no código se eu esquecer de configurar ReportBatchItemFailures?
❓ Quantas tentativas devo permitir antes de mandar uma mensagem para a DLQ?
❓ DLQ sozinha já resolve o problema de mensagem perdida?
❓ Por que devo chamar o gateway de pagamento só depois de gravar no DynamoDB?
Fixando
Seu Lambda processa um lote de 10 mensagens SQS. A mensagem número 7 lança uma exceção não tratada; as outras nove são processadas com sucesso. O event source mapping NÃO tem `ReportBatchItemFailures` configurado. O que acontece?
Seu consumidor grava a chave de idempotência no DynamoDB (`PutItem` condicional) DEPOIS de já ter chamado o gateway de pagamento com sucesso. Qual é a consequência mais provável sob reentrega da mesma mensagem?
Conhecimentos, próximo módulo e documentação
| Item | Conteúdo |
|---|---|
| Conhecimentos anteriores necessários | L21 (modelo de execução do Lambda .NET 8), API de pedidos do L01/L03 no ar, Terraform básico |
| Conhecimentos adquiridos | at-least-once como contrato, não defeito; idempotência real via escrita condicional; visibility timeout e sua relação com o timeout da função; ReportBatchItemFailures e o que falta sem ele; DLQ como observabilidade de falha permanente, não como solução sozinha |
| Limitação que fica | a chamada ao gateway de pagamento ainda não tem retry nem disjuntor próprios — uma instabilidade prolongada do terceiro esgota o `maxReceiveCount` e manda pedidos legítimos para a DLQ |
| Próximo exemplo recomendado | L23 — fanout com SNS. Reutiliza esta fila como um dos assinantes de um evento de pedido compartilhado entre vários consumidores |
| Também habilitado por este módulo | L36 (retry, backoff, disjuntor) resolve a limitação da dependência instável; L25 (Step Functions) trata fluxos com mais de uma etapa de negócio e compensação |
| Data da última validação técnica | 7 de agosto de 2026 |
Documentação oficial consultada: Amazon SQS visibility timeout — o comportamento do relógio e o teto de 12 horas; Amazon SQS standard queues — a garantia de entrega ao menos uma vez; Amazon SQS dead-letter queues — redrive policy e `maxReceiveCount`; Using Lambda with Amazon SQS — polling, batching e o aviso oficial sobre idempotência; Handling errors for an SQS event source in Lambda — o contrato exato de `ReportBatchItemFailures`; e Creating and configuring an Amazon SQS event source mapping — a recomendação de visibility timeout em pelo menos seis vezes o timeout da função, e `maxReceiveCount` de pelo menos 5. Os valores de preço não aparecem neste módulo por decisão: use o AWS Pricing Calculator, porque preço varia por região e envelhece mais rápido que o conteúdo.
O que não foi verificado, e você deve conferir na sua conta
A latência de ~700-800 ms do gateway de pagamento e os números de vazão da campanha (0,5 pedido/s normal, 5 pedidos/s de pico) são os medidos no cenário de exemplo da Cadência, e servem como ordem de grandeza — não como referência para o seu sistema. O timeout da função (10 s) e o visibility timeout (60 s) devem ser derivados do tempo real de processamento medido na sua aplicação, mantendo a proporção de pelo menos 6× recomendada pela AWS, não copiados destes valores.
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…