Lab 23 — Fanout: um evento, vários interessados
O problema, e a empresa que o tem
A Cadência é a mesma equipe de duas pessoas do L01 e do L22: API de pedidos para trinta lojas, em ECS Fargate. Desde o L22, quando um pedido é criado, o checkout publica numa fila SQS de estoque — com DLQ e consumo idempotente — para dar baixa no saldo por SKU.
Na semana passada, o time de atendimento pediu confirmação por e-mail. A forma mais rápida de entregar foi acrescentar, dentro do MESMO método que processa o checkout, uma segunda chamada SendMessageAsync para uma fila nova. Funcionou, e ninguém tratou isso como decisão de arquitetura — foi só mais uma linha.
Hoje o time de dados pediu uma terceira cópia: cada pedido criado precisa cair também numa fila de analytics. E aí o problema aparece — não é a fila em si, é onde ela teria de ser conectada. O método que cria um pedido, o caminho mais sensível do sistema, já tem duas integrações de saída amarradas nele, e a terceira exige abri-lo de novo, testar as duas anteriores de novo, e aceitar que uma exceção na chamada nova pode atrapalhar a resposta ao cliente que só queria confirmar uma compra.
O que este laboratório NÃO é
Não é EventBridge. O barramento com múltiplos TIPOS de evento, regras de roteamento por conteúdo e schema registry é o L24, e ele depende deste. Aqui existe um único tipo de evento — "pedido criado" — distribuído para vários interessados; quando surgir um segundo tipo de evento com regras próprias, é sinal de que chegou a hora do L24, não de esticar este desenho com mais filtros.
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 fanout.
- Explicar por que a lista de consumidores pertence às assinaturas do tópico, e não ao código do produtor.
- Nomear a diferença entre falha de ENTREGA (SNS → fila) e falha de PROCESSAMENTO (fila → consumidor).
- Configurar filtro de mensagem numa assinatura, com o escopo correto, e provar que ele descarta o que não interessa.
- Justificar por que existe uma fila SQS entre o tópico e cada Lambda, em vez de assinar a Lambda direto.
- Adicionar um terceiro consumidor sem alterar uma linha do código do produtor, e provar isso com `git diff`.
- Configurar a política de acesso da fila que autoriza o tópico — e só ele — a publicar nela.
- Reaproveitar a idempotência do L22 num consumidor novo, sabendo que a entrega é "pelo menos uma vez".
- Diagnosticar por que uma fila específica não recebe mensagem, distinguindo filtro, política e RawMessageDelivery.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| Fanout SNS → SQS | SAA-C03, DVA-C02 | um tópico, três filas assinadas | por que publicar para N destinos é diferente de publicar N vezes |
| Filtro de mensagem em assinatura | SAA-C03 | a fila de e-mail só recebe evento de cliente real | o filtro roda na entrega, antes de a fila ver a mensagem |
| SQS entre SNS e o consumidor | SAA-C03, DVA-C02 | fila com DLQ própria por trás de cada assinatura | a diferença entre durabilidade de fila e retry de invocação assíncrona |
| Política de acesso de fila (resource policy) | SAA-C03, SOA-C02 | `Principal: sns.amazonaws.com` com `Condition: SourceArn` | por que "publicar com sucesso" não garante "entregou" |
| DLQ de assinatura vs. DLQ de fila | DVA-C02, SOA-C02 | duas DLQ distintas no mesmo desenho | qual das duas captura falha de entrega e qual captura falha de consumo |
| Desacoplamento e raio de explosão | SAA-C03 | produtor com uma única integração externa | por que N chamadas síncronas dentro de um request aumentam a superfície de falha |
| Entrega pelo menos uma vez | DVA-C02, SAA-C03 | idempotência reaproveitada do L22 num consumidor novo | por que "recebi a mensagem" não implica "recebi uma única vez" |
Onde isto costuma ser cobrado errado
A pergunta clássica descreve um `Publish` que retorna sucesso e pergunta por que nenhuma fila recebeu a mensagem. A resposta quase nunca é o tópico — é a política de acesso da fila, ausente ou mal escrita. "Publicado com sucesso" mede a aceitação pelo SNS, não a entrega ao assinante.
Requisitos, e como cada um muda o desenho
Requisito que não aparece numa linha de Terraform é intenção. A coluna da direita é onde cada um deixou marca.
| Requisito | Valor declarado | O que ele decide no desenho |
|---|---|---|
| O produtor não pode conhecer o número de consumidores | zero acoplamento | tópico SNS central; o checkout publica UMA vez, sempre |
| Cada consumidor tem ritmo e taxa de falha próprios | independência | uma fila SQS por consumidor, nunca fila compartilhada |
| E-mail não pode duplicar em reprocessamento | evento de cliente real, só | filtro de mensagem na assinatura da fila de e-mail, por atributo `origem` |
| Consumidor fora do ar não pode perder mensagem | durabilidade | fila entre o tópico e a Lambda, com `visibility timeout` e DLQ de processamento |
| Terceiro consumidor sem deploy do produtor | entregável deste laboratório | assinatura nova via Terraform; nenhuma mudança no serviço de checkout |
| Distinguir "não entregou" de "não processou" | diagnóstico rápido | DLQ de assinatura (entrega) separada da DLQ de fila (processamento) |
| Nenhuma mensagem duplicada processada duas vezes | idempotência mantida | a trava condicional do L22, reaproveitada em cada consumidor novo |
A hipótese que mantém este desenho simples
Todo requisito acima pressupõe um ÚNICO tipo de evento — "pedido criado" — com vários interessados. Essa hipótese é o que justifica SNS puro em vez de um barramento com schema registry: no dia em que aparecer um segundo tipo de evento com regras de roteamento próprias, ela deixa de valer, e é o sinal de migrar para o L24.
Arquitetura mínima: o produtor que precisa saber quem está ouvindo
Este é o desenho que a Cadência tem hoje, e ele é legítimo como ponto de partida: publica de verdade, com poucas linhas, para dois consumidores. O laboratório começa por tornar o acoplamento visível em código, porque "está tudo amarrado" é opinião e um `grep` é fato.
- → POST /pedidos
- → SendMessage nº1, explícito no código
- → SendMessage nº2, explícito no código
- → evento da fila (trigger SQS)
- → evento da fila (trigger SQS)
- Fora da AWS
- Compute
- Integração de apps
Este desenho publica de verdade: duas filas, duas chamadas, poucas linhas. O defeito não está em nenhuma configuração errada — está em que a lista de destinos mora dentro do método que cria o pedido. Percorra os passos e repare onde a linha do TERCEIRO consumidor entraria.
- O pedido é criado, e a task já sabe demais. O método que atende o `POST /pedidos` grava no Postgres e, na sequência, decide para onde o evento vai. Conhecer o destino é responsabilidade de quem PUBLICA o evento — não deveria ser.
- Cada fila é uma linha escrita à mão, dentro do mesmo método. Duas chamadas `SendMessageAsync`, cada uma com a URL da fila fixa em configuração. Funciona, e é o menor número de linhas que resolve o problema com dois consumidores.
- A fila de estoque já é o padrão do L22, e continua correta. DLQ e idempotência por `pedidoId` já provados ali. Este laboratório não muda o CONSUMO — muda como o evento chega até a fila.
- O terceiro interessado exige abrir este método de novo. O time de dados pediu uma cópia de cada pedido para análise. A única forma de atender, neste desenho, é acrescentar uma TERCEIRA `SendMessageAsync` no mesmo método que já tem duas — o caminho mais sensível do sistema.
- Testar de novo não é cautela, é o preço do acoplamento. Mexer no método de checkout para adicionar a terceira integração obriga a reexecutar o teste das outras duas — elas vivem na mesma função, e uma exceção na chamada nova pode derrubar a resposta ao cliente.
- Por que alguém escreve assim. Com um ou dois consumidores, é genuinamente a solução mais simples — sem tópico, sem política de fila, sem assinatura para configurar. O defeito só aparece quando o número de interessados deixa de ser fixo, e nesse ponto já existem clientes do padrão antigo para não quebrar.
# Quantas integracoes de saida vivem dentro do metodo de checkout, hoje.
grep -c "SendMessageAsync" src/Cadencia.Checkout/PedidosController.cs
# Saida na Cadencia: 2. Cada consumidor novo soma 1 aqui — E soma 1 na
# superficie de teste do metodo mais sensivel do sistema.
# O raio de explosao: se a chamada para a fila de email lancar excecao sem
# tratamento, o que acontece com a resposta ao cliente?
grep -A3 "SendMessageAsync.*filaEmail" src/Cadencia.Checkout/PedidosController.cs
# Na Cadencia: nao ha try/catch ao redor da segunda chamada. Uma excecao ali
# propaga e o POST /pedidos retorna 500 — mesmo com o pedido ja gravado.O pedido já foi criado, e o cliente recebe 500 mesmo assim
No desenho mínimo, se a chamada à fila de e-mail lançar exceção sem tratamento, ela propaga depois de o pedido já estar gravado no Postgres. O cliente vê erro numa compra que, do lado do banco, teve sucesso — e cada consumidor novo é mais uma chance disso acontecer, porque cada `SendMessageAsync` é mais uma chamada síncrona no caminho crítico.
Arquitetura para produção
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 /pedidos
- → Publish único, com atributos de mensagem
- → cópia — sem filtro, recebe tudo
- → cópia — só se origem != interna (filtro)
- → cópia — assinatura nova, sem filtro
- → evento da fila (trigger SQS)
- → evento da fila (trigger SQS)
- → evento da fila (trigger SQS)
- Fora da AWS
- Compute
- Integração de apps
A troca deixa de ser "o produtor chama cada destino" e passa a ser "o produtor publica uma vez, e quem quer se inscreve". O que muda no desenho é a existência de um ponto único de publicação — o tópico — e a lista de destinos sai do código e vira assinatura. Percorra os passos até o consumidor NOVO.
- Publish único — o produtor deixa de enumerar destinos. A task chama `Publish` UMA vez no tópico, com o pedido no corpo e atributos de mensagem (`tipoEvento`, `origem`). Ela não referencia nenhuma fila.
- A lista de destinos mora nas assinaturas, não no código. Cada `aws_sns_topic_subscription` no Terraform é uma linha que declara "esta fila quer o evento" — sem que o produtor precise concordar ou saber.
- Cada fila decide se quer, com filtro na própria assinatura. A fila de e-mail declara, na SUA assinatura, que só quer mensagens em que `origem` não seja reprocessamento interno. O tópico avalia o filtro antes de entregar — a fila nunca vê a mensagem que não lhe interessa.
- A fila é o que dá durabilidade que o Lambda direto não teria. Entre a fila e a Lambda existe fila de verdade: profundidade visível, `visibility timeout` próprio, e uma DLQ de PROCESSAMENTO por consumidor. Um consumidor lento não afeta os outros dois.
- O terceiro consumidor é uma assinatura nova, não um deploy. A fila de analytics e a Lambda que a consome são recursos NOVOS, mas o arquivo do serviço de checkout não muda uma linha. É o entregável deste laboratório, e a prova de implantação confirma isso com `git diff`.
- Cada consumidor tem seu próprio ritmo e sua própria DLQ. Se o consumidor de analytics cair por uma hora, a fila dele acumula — as outras duas seguem intocadas. No desenho mínimo, uma exceção na chamada de analytics podia atrapalhar a resposta ao cliente.
A diferença estrutural em relação ao desenho anterior não é a fila de analytics a mais: é a existência de um ÚNICO ponto de publicação. Adicionar, remover ou mudar um consumidor deixa de tocar o produtor — passa a ser operação inteiramente do lado das assinaturas.
O efeito que não aparece no diagrama: o raio de explosão encolheu
No desenho mínimo, uma falha ao enviar para a fila de e-mail podia derrubar a resposta do checkout. Aqui existe uma única chamada externa no caminho crítico — o Publish no tópico — e a falha de um CONSUMIDOR específico (fila de estoque cheia, Lambda de analytics fora do ar) não chega perto do request que cria o pedido.
Como funciona ponta a ponta
Os nomes dos atributos não são só metadado: são o que o filtro de assinatura compara. Publicar sem eles, ou publicá-los no lugar errado (corpo em vez de atributo), faz o filtro nunca casar — em silêncio.
// Corpo publicado no topico. Com RawMessageDelivery=true, este e o CORPO
// exato que cada fila recebe — sem envelope do SNS por cima.
{
"pedidoId": "b3f1e8c2-4a9d-4e11-9c2a-7d5e0f1a2b3c",
"lojaId": 17,
"valorTotal": 249.90,
"itens": [
{ "sku": "TENIS-42-AZ", "quantidade": 1 }
],
"criadoEm": "2026-08-07T14:32:00Z"
}
// Atributos de mensagem do Publish (NAO vao no corpo — sao metadado do SNS,
// e sao o que o filtro de assinatura compara):
// tipoEvento = "pedido.criado" (String)
// origem = "checkout-cliente" (String) — ou "reprocessamento-interno",
// ou "correcao-manual", conforme quem disparou a criacao do pedido.Publish com sucesso não é sinônimo de entregue
O `Publish` retorna um `MessageId` assim que o SNS aceita a mensagem — isso mede que o TÓPICO recebeu, não que cada assinatura recebeu. Para endpoints gerenciados pela AWS (SQS, Lambda), o SNS retenta a entrega até 100.015 vezes ao longo de 23 dias antes de desistir, o que torna a entrega em si bastante resiliente — mas ela ainda pode falhar de imediato por erro de permissão, e é aí que entra a DLQ de assinatura.
As decisões, e o que se perde em cada uma
📋 A Cadência tem hoje dois consumidores do evento "pedido criado" (estoque e e-mail), acoplados no código do checkout, e o time de dados acabou de pedir um terceiro. Não é a última vez que isso vai acontecer.
O produtor passa a ter UM ponto de integração externa em vez de N, e adicionar consumidor deixa de ser mudança de código — é `terraform apply` de uma assinatura nova. O raio de explosão também encolhe: no desenho acoplado, uma exceção ao chamar a fila de e-mail podia atrapalhar a resposta ao cliente que está criando o pedido; com um único `Publish`, só existe uma chamada externa no caminho crítico.
Alt: Lambda assinado direto no tópico SNS, sem fila — Perde a fila como amortecedor: sem profundidade visível, sem `visibility timeout` próprio, e a única proteção contra falha de processamento é o que o Lambda oferecer nativamente (destino assíncrono em falha), que não tem a mesma granularidade de DLQ por tentativa.
Alt: Continuar publicando direto em cada fila (o desenho mínimo) — É a arquitetura deste módulo antes da mudança. Cada consumidor novo é uma linha a mais no método mais sensível do sistema, e o raio de explosão cresce junto com o número de integrações.
Alt: EventBridge como barramento — Resolve o mesmo problema e mais: múltiplos tipos de evento, regras de roteamento por conteúdo, replay. Para UM tipo de evento com filtro simples por atributo, é mais peça do que a Cadência precisa hoje — é o L24, o próximo passo quando o número de TIPOS de evento crescer, não só de consumidores.
Alt: Webhook HTTP por consumidor — Sem fila, sem retentativa gerenciada, sem DLQ: se o endpoint do consumidor estiver fora do ar no momento da publicação, a mensagem some, a menos que o produtor implemente sua própria fila de retentativa — o que é reinventar o SQS com mais código.
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Ponto de publicação | tópico SNS único | produtor chama cada fila; cada consumidor por webhook | produtor passa a ter uma integração externa, não N | mais uma peça gerenciada para operar, ainda que sem servidor |
| Entre o tópico e a Lambda | fila SQS por consumidor | Lambda assinada direto no tópico | durabilidade, `visibility timeout` e DLQ de processamento granular | latência extra de milissegundos e mais um recurso por consumidor |
| Quem decide o que recebe | filtro na assinatura | filtro dentro do código do consumidor | a fila nunca vê, nem processa, o que não lhe interessa — custo e código a menos | o filtro fica em Terraform, mais distante de quem lê só o C# |
| Formato da mensagem na fila | `RawMessageDelivery = true` | manter o envelope do SNS e desserializar duas vezes | o corpo da fila é o JSON publicado, sem tradução extra em cada consumidor | nada relevante aqui; é praticamente sempre a escolha certa |
| DLQ de entrega separada da de processamento | as duas, com propósitos distintos | uma DLQ só, misturando as duas causas | diagnóstico aponta direto para o lado certo do problema | mais uma fila para monitorar — e ela deve ficar quase sempre vazia |
| Tipo de barramento | SNS (um tipo de evento) | EventBridge | resolve o problema declarado sem regra de roteamento nem schema registry | quando surgir um segundo tipo de evento, este desenho não escala sozinho — é o L24 |
A dívida que o fanout cria, e que este módulo não paga
Um tópico SNS padrão (standard, não FIFO) não garante ordem entre mensagens nem entre filas diferentes. Se dois eventos do mesmo pedido forem publicados em sequência rápida, a fila de estoque e a de analytics podem processá-los em ordens diferentes. Enquanto cada evento for autocontido — como o `pedido.criado` deste módulo — isso não importa; no dia em que a ordem passar a importar, a resposta é um tópico FIFO com deduplicação, não uma correção no consumidor.
Construir: o tópico e as filas que decidem se querem
O `for_each` sobre um mapa de consumidores não é economia de digitação: é o que torna o terceiro consumidor uma ENTRADA NO MAPA, não uma seção nova de código. Adicionar `analytics = { filtro = null }` e rodar `apply` é o laboratório inteiro, do lado do Terraform.
# fanout.tf — um topico, N filas que decidem se querem
resource "aws_sns_topic" "pedido_criado" {
name = "${var.projeto}-pedido-criado"
kms_master_key_id = "alias/aws/sns" # chave gerenciada basta: o payload nao carrega segredo
}
# Cada consumidor tem seu proprio ritmo e sua propria causa de falha — por isso
# fila e DLQ sao POR CONSUMIDOR, nunca compartilhadas. O filtro mora aqui, ao
# lado do que ele protege: quem le a fila de email entende por que ela existe.
locals {
consumidores = {
estoque = {
# Recebe TUDO. Baixa de estoque tem de acontecer mesmo quando o evento
# veio de um reprocessamento — o saldo tem de refletir a realidade.
filtro = null
}
email = {
# So evento de cliente de verdade. Reprocessamento interno e correcao
# manual nao podem gerar confirmacao duplicada para quem ja recebeu a
# primeira. "anything-but" e o operador certo aqui: nega uma lista curta
# em vez de enumerar todos os valores validos, que podem crescer.
filtro = jsonencode({
origem = [{ "anything-but" : ["reprocessamento-interno", "correcao-manual"] }]
})
}
analytics = {
# Fila NOVA deste laboratorio. Recebe tudo, inclusive reprocessamento —
# para quem analisa dado, isso tambem e sinal de negocio.
filtro = null
}
}
}
resource "aws_sqs_queue" "dlq" {
for_each = local.consumidores
name = "${var.projeto}-${each.key}-dlq"
message_retention_seconds = 1209600 # 14 dias — o teto, para dar tempo de investigar
}
resource "aws_sqs_queue" "fila" {
for_each = local.consumidores
name = "${var.projeto}-${each.key}"
visibility_timeout_seconds = 30 # ponto de partida; ajuste por consumidor e' o L22
# Esta e' a DLQ de PROCESSAMENTO: recebe mensagem que a fila ENTREGOU ao
# consumidor e ele nao confirmou apos 5 tentativas. E' diferente da DLQ de
# assinatura abaixo, que e' de ENTREGA — a distincao central deste modulo.
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.dlq[each.key].arn
maxReceiveCount = 5 # herdado do L22
})
}
# A fila nao aceita mensagem de ninguem por padrao. Esta politica autoriza
# ESTE topico — e so ele — a chamar SendMessage. Sem ela, a assinatura existe
# e fica "confirmed", mas toda entrega falha do lado de dentro: o Publish no
# topico continua retornando sucesso, porque publicar != entregar.
resource "aws_sqs_queue_policy" "permite_sns" {
for_each = local.consumidores
queue_url = aws_sqs_queue.fila[each.key].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "sns.amazonaws.com" }
Action = "sqs:SendMessage"
Resource = aws_sqs_queue.fila[each.key].arn
Condition = {
# Sem isto, QUALQUER topico SNS de QUALQUER conta poderia escrever
# nesta fila: o principal "sns.amazonaws.com" sozinho nao distingue
# topicos. O ArnEquals com o SourceArn e' a fronteira real.
ArnEquals = { "aws:SourceArn" = aws_sns_topic.pedido_criado.arn }
}
}]
})
}
# DLQ de ENTREGA — da ASSINATURA, nao da fila. So recebe mensagem se o SNS
# nao conseguir ENTREGAR a fila (ex.: politica revogada, fila apagada). E' um
# erro de lado de fora; a DLQ da fila acima e' erro de lado de dentro.
resource "aws_sqs_queue" "dlq_entrega" {
name = "${var.projeto}-fanout-entrega-dlq"
message_retention_seconds = 1209600
}
resource "aws_sns_topic_subscription" "assinatura" {
for_each = local.consumidores
topic_arn = aws_sns_topic.pedido_criado.arn
protocol = "sqs"
endpoint = aws_sqs_queue.fila[each.key].arn
# Sem isto, o corpo da mensagem SQS vira o envelope do SNS (Type, MessageId,
# TopicArn, Message, Timestamp, assinatura de verificacao...) e o consumidor
# precisaria desserializar duas vezes. Com RawMessageDelivery, o corpo da
# mensagem SQS E' o JSON publicado, e os atributos do Publish viram
# atributos de mensagem do SQS diretamente.
raw_message_delivery = true
filter_policy = each.value.filtro
filter_policy_scope = "MessageAttributes" # e' o padrao; explicito porque a alternativa (MessageBody) exige corpo bem-formado
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.dlq_entrega.arn
})
depends_on = [aws_sqs_queue_policy.permite_sns]
}
output "topico_pedido_criado" {
value = aws_sns_topic.pedido_criado.arn
description = "unico ponto de publicacao; a lista de quem recebe mora nas assinaturas, nao aqui"
}
A política de fila é o passo que mais gente esquece
Sem `aws_sqs_queue_policy`, a assinatura fica "confirmed" no console — parece correta — e mesmo assim nenhuma mensagem chega. O `Principal: sns.amazonaws.com` sozinho não basta: sem a condição `SourceArn`, ou a política não existe, a fila recusa a escrita e a mensagem vai para a DLQ de ENTREGA, não para a fila. É o defeito mais silencioso deste laboratório.
Construir: o papel de cada consumidor, e nada além disso
Um papel IAM por consumidor, não um papel compartilhado. A fila de e-mail e a de analytics não têm nada em comum do ponto de vista de permissão, e dar ao consumidor de e-mail acesso à fila de estoque só porque "é mais rápido de configurar" reintroduz acoplamento pela porta dos fundos.
# consumo.tf — cada consumidor le so a fila dele, e nada alem disso
data "aws_iam_policy_document" "publicar_evento" {
statement {
effect = "Allow"
actions = ["sns:Publish"]
resources = [aws_sns_topic.pedido_criado.arn] # so este topico; a task nunca fala com SQS direto
}
}
resource "aws_iam_role_policy" "checkout_publica" {
role = aws_iam_role.task.id # o mesmo papel de aplicacao do L01/L22 — nao o de execucao
policy = data.aws_iam_policy_document.publicar_evento.json
}
# Um papel POR consumidor. O de email nao consegue ler a fila de estoque, e
# vice-versa — isolamento que a arquitetura de filas separadas so entrega se
# o IAM acompanhar.
data "aws_iam_policy_document" "consumir_fila" {
for_each = local.consumidores
statement {
effect = "Allow"
actions = [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes",
]
resources = [aws_sqs_queue.fila[each.key].arn]
}
}
resource "aws_iam_role" "consumidor" {
for_each = local.consumidores
name = "${var.projeto}-${each.key}-consumidor"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
resource "aws_iam_role_policy" "consumidor_le_fila" {
for_each = local.consumidores
role = aws_iam_role.consumidor[each.key].id
policy = data.aws_iam_policy_document.consumir_fila[each.key].json
}
resource "aws_lambda_function" "consumidor" {
for_each = local.consumidores
function_name = "${var.projeto}-${each.key}"
role = aws_iam_role.consumidor[each.key].arn
handler = "Consumidor::Consumidor.Funcao::Executar"
runtime = "dotnet8"
filename = "build/${each.key}.zip"
timeout = 30
}
resource "aws_lambda_event_source_mapping" "consumo" {
for_each = local.consumidores
event_source_arn = aws_sqs_queue.fila[each.key].arn
function_name = aws_lambda_function.consumidor[each.key].arn
batch_size = 10
function_response_types = ["ReportBatchItemFailures"] # falha de UM item do lote nao reprocessa o lote inteiro
}
`ReportBatchItemFailures` evita reprocessar o lote inteiro
Sem essa opção no `event_source_mapping`, uma falha em UMA mensagem de um lote de 10 faz o SQS reentregar as 10 depois do `visibility timeout` — inclusive as 9 que já tinham sido processadas com sucesso. Com ela, o consumidor informa QUAIS itens do lote falharam, e só esses voltam à fila.
Construir: o produtor publica uma vez
A mudança real, do lado do checkout, é pequena em linhas e grande em consequência: sai a lista de filas, entra um único destino. O método deixa de crescer a cada consumidor novo.
// PedidosController.cs — trecho: publica UMA vez, nao sabe quem esta ouvindo
[HttpPost]
public async Task<IActionResult> Criar(CriarPedidoRequest req)
{
var pedido = await _pedidos.CriarAsync(req); // grava no Postgres, mesma transacao de negocio
// Ate a semana passada, este metodo tinha DUAS chamadas SendMessageAsync
// explicitas — uma por fila. Agora ele nao sabe, e nao precisa saber,
// quantos sistemas se interessam pelo pedido criado.
var evento = new
{
pedidoId = pedido.Id,
lojaId = pedido.LojaId,
valorTotal = pedido.ValorTotal,
itens = pedido.Itens.Select(i => new { sku = i.Sku, quantidade = i.Quantidade }),
criadoEm = pedido.CriadoEm
};
await _sns.PublishAsync(new PublishRequest
{
TopicArn = _config["Sns:TopicoPedidoCriado"],
Message = JsonSerializer.Serialize(evento),
MessageAttributes = new Dictionary<string, MessageAttributeValue>
{
["tipoEvento"] = new() { DataType = "String", StringValue = "pedido.criado" },
// "origem" e' o atributo que a assinatura da fila de email filtra.
// Uma reexecucao em lote usa "reprocessamento-interno" e nao gera
// e-mail duplicado para quem ja recebeu o primeiro.
["origem"] = new()
{
DataType = "String",
StringValue = req.OrigemInterna ? "reprocessamento-interno" : "checkout-cliente"
}
}
});
// Se o Publish falhar (rede, throttling), a exceção propaga daqui — e e'
// a UNICA chamada externa no caminho critico. No desenho anterior, eram
// duas, e uma terceira estava para chegar.
return Ok(pedido);
}
O atributo "origem" é contrato, não detalhe de implementação
Se alguém publicar um evento sem o atributo `origem`, ou com um valor fora dos três combinados, o filtro da assinatura de e-mail — que usa `anything-but` — deixa a mensagem PASSAR, porque um atributo ausente não corresponde a nenhum valor da lista de exclusão. É a razão para tratar `origem` como parte do contrato do evento, com o valor sempre explícito no código, nunca opcional.
Construir: o consumidor de e-mail, e a idempotência herdada do L22
Nenhuma novidade na tática de idempotência — é o mesmo `PutItem` condicional do consumidor de estoque, só que numa tabela separada. O que muda é a origem da mensagem: antes vinha de uma fila que só o checkout escrevia; agora vem de uma fila que o SNS escreve, com o mesmo corpo.
// FuncaoEmailConfirmacao.cs — a mensagem pode chegar mais de uma vez (SNS e
// SQS entregam "pelo menos uma vez"); a idempotencia e' a mesma tatica do L22.
public async Task FunctionHandler(SQSEvent evento, ILambdaContext contexto)
{
foreach (var msg in evento.Records)
{
// O corpo JA' e' o JSON puro do pedido: RawMessageDelivery=true tirou
// o envelope do SNS. Sem isso, este Deserialize leria {Type, MessageId,
// TopicArn, Message: "..."} em vez do pedido.
var pedido = JsonSerializer.Deserialize<PedidoCriado>(msg.Body)!;
try
{
// A trava de idempotencia: so um PutItem por pedidoId vence. E' o
// MESMO padrao do consumidor de estoque do L22 — reaproveitado,
// nao reinventado.
await _dynamo.PutItemAsync(new PutItemRequest
{
TableName = "idempotencia-email",
Item = new Dictionary<string, AttributeValue>
{
["pedidoId"] = new AttributeValue { S = pedido.PedidoId },
["expiraEm"] = new AttributeValue
{
N = DateTimeOffset.UtcNow.AddDays(7).ToUnixTimeSeconds().ToString()
},
},
ConditionExpression = "attribute_not_exists(pedidoId)",
});
}
catch (ConditionalCheckFailedException)
{
// Segunda entrega do mesmo pedidoId: ja processamos, nao reenvia
// e-mail. Nao e' erro — e' o comportamento esperado de "pelo
// menos uma vez".
continue;
}
await _ses.SendEmailAsync(MontarEmailConfirmacao(pedido));
}
}
Implantar, e provar que o terceiro consumidor não tocou o produtor
Cinco provas. A terceira é a que sustenta o núcleo deste laboratório, e nenhuma delas aceita "chegou, acho" como resultado.
# provas.sh — cinco medicoes; nenhuma conclusao vem de "parece que chegou"
PROJETO=ffv-lab; REGIAO=us-east-1
TOPICO=$(terraform output -raw topico_pedido_criado)
# ── Prova 1: um Publish, tres filas recebem cada uma sua copia ───────────────
aws sns publish --topic-arn "$TOPICO" --region "$REGIAO" \
--message '{"pedidoId":"prova-001","lojaId":17,"valorTotal":99.9,"itens":[],"criadoEm":"2026-08-07T15:00:00Z"}' \
--message-attributes '{"tipoEvento":{"DataType":"String","StringValue":"pedido.criado"},"origem":{"DataType":"String","StringValue":"checkout-cliente"}}'
for f in estoque email analytics; do
n=$(aws sqs get-queue-attributes \
--queue-url "$(terraform output -json filas | jq -r .$f)" \
--attribute-names ApproximateNumberOfMessages \
--query 'Attributes.ApproximateNumberOfMessages' --output text)
echo "fila $f: $n mensagem(ns)"
done
# Esperado: as tres filas mostram pelo menos 1. Na medicao de referencia deste
# modulo, as tres chegaram em menos de 2 s do Publish.
# ── Prova 2: o filtro funciona — reprocessamento NAO gera e-mail ─────────────
aws sns publish --topic-arn "$TOPICO" --region "$REGIAO" \
--message '{"pedidoId":"prova-002","lojaId":17,"valorTotal":10,"itens":[],"criadoEm":"2026-08-07T15:01:00Z"}' \
--message-attributes '{"tipoEvento":{"DataType":"String","StringValue":"pedido.criado"},"origem":{"DataType":"String","StringValue":"reprocessamento-interno"}}'
sleep 3
aws sqs get-queue-attributes \
--queue-url "$(terraform output -json filas | jq -r .email)" \
--attribute-names ApproximateNumberOfMessages --query 'Attributes.ApproximateNumberOfMessages'
# Esperado: 0 mensagens NOVAS na fila de email (a da prova 1 ja deve ter sido
# consumida). Estoque e analytics, ao contrario, recebem esta tambem.
# ── Prova 3: o terceiro consumidor nao tocou o produtor ──────────────────────
git diff --stat origin/main -- src/Cadencia.Checkout/
# Esperado: zero linhas no diretorio do servico de checkout. A fila e a
# Lambda de analytics aparecem so no diff do Terraform.
# ── Prova 4: durabilidade — consumidor fora do ar nao perde mensagem ─────────
aws lambda put-function-concurrency \
--function-name "${PROJETO}-estoque" --reserved-concurrent-executions 0
aws sns publish --topic-arn "$TOPICO" --region "$REGIAO" \
--message '{"pedidoId":"prova-004","lojaId":17,"valorTotal":10,"itens":[],"criadoEm":"2026-08-07T15:02:00Z"}' \
--message-attributes '{"tipoEvento":{"DataType":"String","StringValue":"pedido.criado"},"origem":{"DataType":"String","StringValue":"checkout-cliente"}}'
sleep 5
aws sqs get-queue-attributes \
--queue-url "$(terraform output -json filas | jq -r .estoque)" \
--attribute-names ApproximateNumberOfMessagesNotVisible,ApproximateAgeOfOldestMessage
aws lambda delete-function-concurrency --function-name "${PROJETO}-estoque"
# Esperado: a mensagem fica RETIDA na fila (idade > 0, visivel apos o
# visibility timeout) em vez de se perder. E' a diferenca central em relacao
# a assinar a Lambda direto no topico.
# ── Prova 5: mensagem malformada cai na DLQ de PROCESSAMENTO, nao na de entrega
aws sqs send-message \
--queue-url "$(terraform output -json filas | jq -r .email)" \
--message-body '{"pedidoId":"prova-005-sem-campos-esperados"}'
sleep 2
aws sqs get-queue-attributes \
--queue-url "$(terraform output -raw fila_email_dlq)" \
--attribute-names ApproximateNumberOfMessages
# Esperado: 1 mensagem na DLQ da FILA apos 5 tentativas (redrive_policy da
# fila) — nao na DLQ da assinatura, que so trata falha de ENTREGA do SNS.
| Prova | Comando | Resultado que aprova | O que reprova, e o que significa |
|---|---|---|---|
| 1 · Uma publicação, três cópias | `sns publish` + `get-queue-attributes` × 3 | as três filas mostram ao menos 1 mensagem | fila com 0 e sem política de acesso: falha de entrega, veja a DLQ de assinatura |
| 2 · O filtro descarta o que não interessa | publicar com `origem=reprocessamento-interno` | fila de e-mail não ganha mensagem nova; estoque e analytics ganham | se a de e-mail também recebeu, o `filter_policy_scope` ou o valor do atributo está errado |
| 3 · O produtor não mudou | `git diff --stat` no diretório do checkout | zero linhas alteradas | qualquer linha aqui é sinal de que o consumidor novo ainda está acoplado ao produtor |
| 4 · A fila retém, não perde | zerar concorrência da Lambda e publicar | a mensagem fica visível como retida na fila, com idade > 0 | mensagem ausente da fila indicaria assinatura direta sem buffer — não é este desenho |
| 5 · A DLQ certa recebe a mensagem errada | mensagem malformada direto na fila | aparece na DLQ da FILA após 5 tentativas, não na DLQ de entrega | se caiu na de entrega, o problema está sendo classificado no lado errado do fanout |
Quebrar de propósito: três falhas e o diagnóstico
As três produzem o mesmo sintoma superficial — "a fila não recebeu o que eu esperava" — e cada uma exige olhar num lugar diferente do desenho.
| Falha | Como provocar | Sintoma | Onde olhar | Correção |
|---|---|---|---|---|
| Política de fila ausente | remova (ou nunca aplique) `aws_sqs_queue_policy` numa das filas | Publish retorna sucesso; a fila fica em 0 mensagens para sempre | DLQ de entrega (`dlq_entrega`) acumulando; `NumberOfNotificationsFailed` do tópico | aplicar a `queue_policy` com `Principal sns.amazonaws.com` e `Condition SourceArn` |
| Escopo de filtro trocado | publique com o atributo em `MessageAttributes`, mas deixe `filter_policy_scope = "MessageBody"` | fila de e-mail permanece vazia mesmo com pedidos de cliente real chegando | `NumberOfNotificationsFilteredOut` do tópico subindo junto com publicações legítimas | usar `MessageAttributes` (o padrão) ou mover o filtro para dentro do corpo JSON, coerentemente |
| RawMessageDelivery esquecido | crie a assinatura sem `raw_message_delivery = true` | consumidor lança exceção de desserialização, ou lê campos nulos do pedido | corpo cru da mensagem na fila: começa com `{"Type":"Notification"...}` em vez do pedido | ligar `raw_message_delivery` na assinatura — não compensar com parsing no consumidor |
A falha que parece "SNS não está entregando" e não é
Publish com sucesso e fila vazia é quase sempre política de acesso ausente, não defeito do SNS. Investigar o tópico primeiro custa tempo: a causa mora do lado da FILA, na política que autoriza — ou não — a escrita. A DLQ de entrega existe para transformar essa suspeita em fato observável em vez de em teoria.
Depois de migrar o checkout da Cadência para publicar num tópico SNS com fanout, o time de dados pede um quarto consumidor. Por que essa mudança não exige tocar no código do serviço de checkout?
Segurança: quem pode publicar, e quem pode escrever em cada fila
Fanout multiplica pontos de confiança: agora existem um produtor, um tópico e N filas, e cada elo precisa da permissão mínima para o papel que exerce ali.
| Risco | Probabilidade | Impacto | Prevenção | Detecção | Resposta |
|---|---|---|---|---|---|
| Fila aceita mensagem de tópico de outra conta ou de outro tópico | baixa | alto | `Condition: SourceArn` na política da fila, nunca `Principal: "*"` | CloudTrail em `SendMessage` com `sourceArn` inesperado | corrigir a política; auditar mensagens recebidas na janela do incidente |
| Papel do checkout com permissão além de `sns:Publish` no tópico | média | médio | política restrita ao ARN do tópico; nunca `sns:*` nem recurso `*` | IAM Access Analyzer sobre uso real do papel | derivar a política do uso medido — é o L41 |
| Consumidor de e-mail com acesso à fila de estoque | média | médio | um papel IAM por consumidor, restrito à própria fila | revisão do `for_each` de papéis: um por chave do mapa | separar os papéis; nunca compartilhar por conveniência de deploy |
| Filtro mal escrito expõe evento de reprocessamento a e-mail do cliente | média | baixo | teste automatizado que publica os três valores de `origem` e confere a fila de e-mail | auditoria manual de e-mails enviados vs. `origem` do pedido correspondente | corrigir o `filter_policy`; a prova 2 desta seção existe para pegar isso antes de produção |
| Dado sensível no payload do evento (se um dia o pedido carregar CPF) | baixa | alto | este módulo não trata mascaramento — declare a hipótese e revise antes de adicionar campo sensível | revisão de payload em toda mudança de contrato de evento | mascarar ou tokenizar antes de publicar; nunca depois, quando já está em três filas |
Por que a `Condition` na política da fila não é opcional
`Principal: sns.amazonaws.com` sozinho autoriza QUALQUER tópico SNS de QUALQUER conta que descubra o ARN da fila. A condição `aws:SourceArn` restringe a autorização a este tópico específico. É a diferença entre "qualquer SNS pode escrever aqui" e "só este tópico pode".
Observabilidade: as perguntas que o painel do fanout tem de responder
Um painel de fanout tem uma função que o de uma fila única não tem: dizer se o problema está na ENTREGA (tópico → fila) ou no PROCESSAMENTO (fila → consumidor), porque as correções são completamente diferentes.
| Pergunta | Métrica ou consulta | O que significa mudar | Limiar inicial |
|---|---|---|---|
| O tópico está falhando em entregar a algum assinante? | `NumberOfNotificationsFailed` (SNS) | política de fila, assinatura removida ou fila apagada | > 0 em qualquer período |
| O filtro está descartando mais do que deveria? | `NumberOfNotificationsFilteredOut` (SNS) | atributo ausente no Publish, ou regra de filtro incoerente com o contrato | crescimento súbito face ao volume normal |
| Alguma fila está acumulando sem ser consumida? | `ApproximateNumberOfMessagesVisible` por fila | consumidor lento, parado, ou com concorrência reduzida | depende do throughput normal de cada fila |
| A mensagem mais antiga está envelhecendo? | `ApproximateAgeOfOldestMessage` por fila | sinal de consumidor que parou de avançar, não só de pico | > 5 min para filas de latência baixa esperada |
| Chegou mensagem na DLQ de entrega? | `ApproximateNumberOfMessagesVisible` da `dlq_entrega` | falha do lado de FORA da fila — política, assinatura, permissão | > 0 sempre é investigável |
| Chegou mensagem na DLQ de uma fila específica? | `ApproximateNumberOfMessagesVisible` de cada `*-dlq` | falha de PROCESSAMENTO daquele consumidor específico, isolada dos outros dois | > 0 sempre é investigável |
| O consumidor de e-mail está sendo invocado no ritmo esperado? | Lambda `Invocations`, `Errors`, `Throttles` | throttling por concorrência reservada baixa, ou erro sistemático no handler | Errors > 1% das invocações |
A métrica que confunde entrega com processamento
`ApproximateNumberOfMessagesVisible` alto numa fila pode significar duas coisas opostas: o consumidor está devagar (processamento) ou a fila nunca devia estar recebendo tanto assim (filtro mal ajustado, entrega indevida). Cruzar com `NumberOfNotificationsFilteredOut` do tópico — que deveria estar ALTO quando o filtro funciona — separa as duas histórias.
Escala: 10, 10 mil, 1 milhão de eventos por dia
| Volume | O que acontece com o fanout | O que passa a doer | O que fazer |
|---|---|---|---|
| 10 pedidos/dia, 3 assinantes | 30 entregas totais; nada relevante | nada; é o cenário do laboratório | nada |
| 9 mil pedidos/dia (Cadência hoje), 3 assinantes | 27 mil entregas/dia | ainda desprezível para SNS e SQS; o Lambda de menor concorrência pode formar fila | ajustar concorrência reservada do consumidor mais lento, não do tópico |
| 1 milhão de eventos/dia, 3 assinantes | 3 milhões de entregas/dia | cada consumidor novo MULTIPLICA a carga a partir do mesmo Publish — o 4º consumidor não soma, ele adiciona 1 milhão de invocações a mais para SI, não para os outros | dimensionar concorrência de Lambda por consumidor; considerar lote maior no event source mapping |
| Um consumidor específico fica lento | só a fila dele cresce | no desenho mínimo isso poderia atrasar ou falhar o checkout; aqui, isolado | é o comportamento esperado — alarme na fila daquele consumidor, sem tocar nos outros |
| Pico de 10× em cima do fanout | SNS e SQS absorvem sem configuração extra | o gargalo desloca para a concorrência de Lambda de cada consumidor | reservar concorrência mínima para o consumidor mais crítico (estoque) |
| Um quarto tipo de evento aparece (não mais um consumidor, um TIPO) | o filtro por atributo começa a acumular regras | manutenção do `filter_policy` fica difícil de auditar com muitas condições | é o sinal de migrar para EventBridge (L24), que tem regras dedicadas por tipo de evento |
O número que ninguém olha, e que é o que realmente cresce com fanout
O `Publish` no produtor não muda de custo com o número de consumidores. Quem cresce é a contagem de ENTREGAS do SNS — uma por assinatura que casa com o filtro. Um quarto consumidor não pesa nada a mais no checkout; pesa exatamente 1 entrega extra por evento publicado, do lado do tópico para fora.
Custo: o que este laboratório acrescenta à fatura
SNS e SQS cobram por uso, não por hora ligada — não existe recurso ocioso aqui como NAT Gateway. A dimensão que surpreende não é o preço unitário, é a multiplicação: cada consumidor a mais multiplica entregas, não soma.
| Cenário | Volume | O que acrescenta | Tendência | Otimização |
|---|---|---|---|---|
| Protótipo | 10 pedidos/dia, 3 assinantes | algumas dezenas de publicações e entregas por dia | desprezível | nenhuma; otimizar aqui é gastar atenção onde não há dinheiro |
| Produção pequena (Cadência hoje) | 9 mil pedidos/dia, 3 assinantes | 9 mil publicações e cerca de 27 mil entregas por dia | baixa e previsível | nenhuma ação necessária até crescer uma ordem de grandeza |
| Alta escala | 1 milhão de pedidos/dia, 5 assinantes | 1 milhão de publicações e 5 milhões de entregas por dia | a linha de SNS passa a aparecer na fatura, dominada por entregas, não por publicações | filtro mais estrito reduz entregas que o consumidor descartaria de qualquer forma |
| Dimensão | Cobra por | Cuidado |
|---|---|---|
| SNS — publicação | requisição de `Publish` | uma por pedido criado, independente do número de assinantes |
| SNS — entrega | notificação entregue a cada assinatura | multiplica pelo número de assinaturas que casam com o filtro |
| SQS — requisição | lote de até 10 mensagens por chamada | consumo em lote (`batch_size`) reduz o número de requisições cobradas |
| Lambda — invocação e duração | por invocação e por GB-segundo | cresce junto com a fila que o alimenta, isolado por consumidor |
| CloudWatch Logs | GB ingerido e retido | três consumidores geram três grupos de log; retenção curta em teste |
| DLQ (4 filas adicionais) | requisição de leitura/escrita, quase sempre vazias | custo desprezível se o sistema está saudável — presença de custo aqui é sinal de problema, não de uso normal |
Onde o filtro de mensagem também é otimização de custo
A fila de e-mail poderia receber tudo e descartar reprocessamento DENTRO do consumidor — funcionaria, mas cada mensagem descartada ainda teria sido entregue (cobrada) e invocado uma Lambda (cobrada) para nada. O filtro na assinatura corta isso na origem: mensagem que não casa nunca chega a virar entrega nem invocação.
Well-Architected nos seis pilares
| Pilar | Situação ao fim deste laboratório | Risco que fica | Melhoria | Prioridade |
|---|---|---|---|---|
| Excelência operacional | consumidor novo é mudança de infraestrutura, não de aplicação | ainda não há contrato de schema versionado para o evento publicado | schema registry e versionamento do evento (L24) | média |
| Segurança | política de fila restrita por `SourceArn`; papel IAM por consumidor | payload em texto claro por três filas; sem mascaramento se surgir dado sensível | revisar contrato do evento antes de adicionar campo pessoal | alta |
| Confiabilidade | fila com DLQ própria por consumidor; DLQ de entrega separada | tópico standard não garante ordem entre eventos do mesmo pedido | tópico FIFO com deduplicação, se a ordem passar a importar | média |
| Eficiência de performance | processamento em lote com falha parcial de item isolada | concorrência de Lambda ainda não dimensionada por criticidade do consumidor | concorrência reservada mínima para o consumidor de estoque | média |
| Otimização de custos | filtro reduz entrega e invocação desnecessárias | nenhum hoje, dado o volume atual | revisar quando o número de assinantes ultrapassar cinco | baixa |
| Sustentabilidade | nenhum recurso ocioso cobrando por hora — tudo aqui é sob demanda | nenhum identificado | nenhuma ação necessária | baixa |
O pilar que este laboratório resolve melhor do que os outros
Confiabilidade é onde o fanout entrega o ganho mais concreto: a falha de um consumidor específico deixou de ser capaz de afetar os outros dois, e cada um tem sua própria DLQ para isolar o diagnóstico. Segurança e excelência operacional ainda têm dívida real — mascaramento de dado sensível e contrato de schema — e nenhuma das duas foi resolvida por acidente só porque o fanout existe.
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.
Produtor chama cada fila diretamente, uma `SendMessageAsync` por consumidor. É onde a Cadência estava, e continua legítimo com um ou dois consumidores fixos.SNS fanout para N filas SQS, cada uma com DLQ de processamento; filtro de mensagem por assinatura; DLQ de entrega no nível do tópico.EventBridge substitui o tópico quando aparecem outros tipos de evento além de "pedido criado" — pagamento aprovado, pedido cancelado — cada um com regras de roteamento próprias.Schema registry e versionamento semântico do evento (`pedido.criado.v1`, `v2`), para consumidores evoluírem em ritmos diferentes sem quebrar uns aos outros.Arquivamento e replay de eventos (por exemplo, trilha em S3 a partir do barramento), para reconstruir o estado de um consumidor a partir de um ponto no tempo.A fila de analytics vira entrada de um pipeline quase em tempo real — ingestão para um data lake e, eventualmente, detecção de padrão anômalo de pedidos.A ordem não é negociável, e o motivo é concreto
EventBridge no nível 3 sem o filtro de assinatura do nível 2 já dominado significa reconstruir, com regras mais complexas, exatamente o problema que o filtro simples já resolvia. Quem pula direto para o barramento de múltiplos tipos de evento sem nunca ter operado UM tipo com N assinantes chega lá sem saber diagnosticar entrega de processamento — a distinção que este laboratório existe para ensinar.
Onde IA entra nesta arquitetura, e onde não entra
Fanout é roteamento determinístico: um evento, um conjunto de assinaturas, um filtro declarativo por atributo. Nenhuma parte disso se beneficia de um modelo — decidir quem recebe o quê é exatamente o tipo de regra que um `filter_policy` resolve melhor do que qualquer classificador, porque é auditável, testável e não erra por probabilidade.
Há um lugar, adiante na cadeia, onde IA acrescentaria valor real: a fila de analytics, no nível 6 da evolução acima, é o ponto de entrada natural para detecção de padrão anômalo em volume de pedidos — picos que fogem do esperado por loja, horário ou SKU. Isso está fora do escopo deste laboratório porque depende de histórico que a Cadência ainda não tem: a fila de analytics criada aqui é o que começa a gerar esse histórico, não o que já pode ser analisado por um modelo.
| Pergunta | Resposta honesta para este módulo |
|---|---|
| Qual problema a IA resolveria aqui? | nenhum, no fanout em si — é roteamento por regra, não por inferência |
| Por que não usar IA para decidir o filtro de cada assinatura? | a regra de negócio ("e-mail não pode duplicar em reprocessamento") é conhecida e estável; um modelo trocaria uma decisão auditável por uma probabilística sem ganho nenhum |
| Onde, então, IA teria lugar nesta cadeia? | na fila de analytics, depois que ela acumular histórico suficiente para detecção de anomalia ter base estatística |
| Por que não agora? | a fila de analytics é criada NESTE laboratório; não existe histórico para treinar nem para avaliar um modelo ainda |
O uso de IA que parece atraente e é armadilha aqui
Pedir a um modelo para "decidir quais eventos cada consumidor deveria receber" trocaria um filtro determinístico e testável por uma decisão que pode mudar sem aviso entre duas execuções. Roteamento de evento é o tipo de problema onde a regra simples não é uma versão inferior da solução com IA — é a solução certa.
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 |
|---|---|---|---|---|---|
| Lambda assinada direto no tópico, sem fila | menos peças, menos Terraform, parece mais simples | sem buffer nem DLQ de processamento granular; throttling de Lambda em pico não tem para onde acumular com a mesma visibilidade de uma fila | picos de tráfego "perdem" eventos sem rastro fácil de investigar | SQS entre o tópico e a Lambda, sempre | notificação best-effort onde perda é tolerável — um alerta informativo, não um evento de negócio |
| Filtro dentro do código do consumidor, mantendo a assinatura sem filtro | parece "mais fácil de entender lendo o C#" | a fila recebe e paga por mensagem que vai ser descartada de qualquer forma; a decisão de quem recebe volta a ficar espalhada | custo de entrega e invocação sem efeito nenhum no comportamento final | filtro na assinatura, ao lado da definição de quem é aquele consumidor | nunca, para filtro simples por atributo — só quando a regra depende de dado que não existe no atributo |
| `Principal: "*"` na política da fila SQS | copiado de um exemplo antigo que só queria "fazer funcionar" | qualquer tópico SNS de qualquer conta pode escrever na fila | mensagem inesperada aparecendo na fila, sem correspondência com nenhum Publish do seu tópico | `Principal: sns.amazonaws.com` com `Condition: aws:SourceArn` apontando para o tópico específico | nunca em produção |
| Uma fila única compartilhada entre consumidores não relacionados | "menos filas para gerenciar" parece economia | `visibility timeout` tem de servir o consumidor mais lento; a DLQ mistura falha de dois domínios; escalar um consumidor arrasta o outro | fila lenta por causa de um handler que nada tem a ver com o outro | uma fila por consumidor, sempre — mesmo que pareçam pequenos hoje | nunca, exceto protótipo descartável de um dia |
| Esquecer `RawMessageDelivery` e compensar com parsing manual do envelope no consumidor | descobriu o problema em produção e corrigiu no lado que estava mexendo | todo consumidor futuro reinventa o mesmo parsing, e ninguém documenta o porquê | cada Lambda tem uma função ligeiramente diferente de "extrair mensagem do envelope SNS" | `raw_message_delivery = true` na assinatura, resolvido uma vez para todos os consumidores | nunca — é sempre mais barato corrigir na assinatura |
| Confiar só na DLQ da fila, sem DLQ de assinatura | a distinção entre entrega e processamento não parece relevante até faltar | falha de ENTREGA (política ausente, fila apagada) não deixa rastro nenhum — a mensagem simplesmente desaparece | "o SNS não está entregando" sem nenhuma evidência para investigar | DLQ de assinatura configurada desde o início, mesmo que fique vazia o tempo todo | nunca — o custo de tê-la é desprezível e vazia é o resultado esperado |
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Onde olhar | Correção |
|---|---|---|---|---|
| Nenhuma fila recebe nada, `Publish` retorna sucesso | falta a política de acesso (`queue_policy`) numa ou em todas as filas | confira a política de cada fila e compare o `SourceArn` exigido com o ARN real do tópico | `aws sqs get-queue-attributes --attribute-names Policy`; `dlq_entrega` | aplicar a política com `Principal sns.amazonaws.com` e `Condition SourceArn` |
| Uma fila específica está sempre vazia, as outras recebem normalmente | filtro de assinatura não casa com o atributo publicado | compare os atributos do `Publish` com o `filter_policy` daquela assinatura, atributo por atributo | `NumberOfNotificationsFilteredOut` do tópico; `filter_policy_scope` | corrigir o atributo publicado ou a expressão do filtro |
| Consumidor lança exceção de desserialização em toda mensagem | `RawMessageDelivery` desabilitado; o corpo é o envelope do SNS | leia o corpo cru de uma mensagem sem deletar (visibilidade zero) antes de processar | console SQS "Send and receive messages"; começa com `{"Type":"Notification"` | habilitar `raw_message_delivery = true` na assinatura |
| Mensagens duplicadas processadas pelo consumidor de e-mail | entrega "pelo menos uma vez" é normal; falta idempotência no handler | confira se existe `PutItem` condicional (ou equivalente) antes de disparar o e-mail | log do consumidor: dois processamentos com o mesmo `pedidoId` | aplicar o padrão de idempotência do L22 — trava condicional por `pedidoId` |
| Terceiro consumidor "não recebe nada" logo após criado | assinatura ainda em `PendingConfirmation`, ou `apply` não incluiu a política da fila nova | `aws sns list-subscriptions-by-topic` e confira o status de cada `SubscriptionArn` | coluna `SubscriptionArn`: deve ser um ARN completo, não a string `PendingConfirmation` | reaplicar o Terraform; assinatura SQS confirma automaticamente ao criar via `aws_sns_topic_subscription` |
| Uma fila específica acumula e envelhece, as outras estão normais | o CONSUMIDOR daquela fila está lento ou parado — não é problema do fanout | `ApproximateAgeOfOldestMessage` daquela fila; `CloudWatch Logs` do Lambda correspondente | métricas do Lambda: `Throttles`, `Errors`, concorrência reservada | investigar o consumidor isoladamente — é exatamente o isolamento que a arquitetura entrega |
| Mensagem aparece na DLQ de entrega em vez da DLQ da fila | falha do lado de FORA da fila: política revogada, fila apagada, permissão retirada | compare o horário da mensagem na DLQ de entrega com mudanças recentes de infraestrutura | `aws_sqs_queue_policy` e histórico de mudanças (CloudTrail em `SetQueueAttributes`) | restaurar a política ou a fila; redirecionar as mensagens presas manualmente |
A pergunta que resolve metade destes casos
Antes de mexer em qualquer configuração, pergunte: a mensagem chegou a EXISTIR na fila, ou nunca chegou? Se a fila está em zero e a DLQ de entrega tem conteúdo, o problema é entre o tópico e a fila. Se a fila recebeu e o consumidor não avançou, o problema é entre a fila e o código. As duas metades do desenho falham de formas completamente diferentes.
Limpeza: o que o destroy não leva
Este laboratório cria só recursos sob demanda — nada cobra por hora ligada. Mesmo assim, dois pontos merecem conferência depois do `destroy`.
# 1. Derrube o que o Terraform administra.
terraform destroy -auto-approve
# 2. MENSAGENS PRESAS NAS DLQ: o destroy remove a fila, mas se voce quer
# investigar antes de perder o conteudo, drene primeiro.
for f in estoque email analytics fanout-entrega; do
aws sqs receive-message --queue-url "$(aws sqs get-queue-url \
--queue-name ffv-lab-${f}-dlq --query QueueUrl --output text 2>/dev/null)" \
--max-number-of-messages 10 2>/dev/null || true
done
# 3. ASSINATURAS: normalmente saem com o destroy, mas se a fila foi apagada
# manualmente ANTES do topico, a assinatura pode ficar orfa ("confirmed"
# apontando para um ARN que nao existe mais). Confira.
aws sns list-subscriptions-by-topic --topic-arn "$(terraform output -raw topico_pedido_criado 2>/dev/null)" 2>/dev/null || \
echo "topico ja removido — confira subscricoes orfas manualmente no console"
# 4. GRUPOS DE LOGS de cada Lambda: retencao propria, nao pertencem ao ciclo
# da funcao.
for f in estoque email analytics; do
aws logs delete-log-group --log-group-name "/aws/lambda/ffv-lab-${f}" 2>/dev/null || true
done
# 5. Prova final: nada com nome do projeto de pe.
aws resourcegroupstaggingapi get-resources \
--tag-filters Key=Projeto,Values=ffv-lab \
--query "ResourceTagMappingList[].ResourceARN" --output table| Recurso | Sai no destroy? | Cobra parado? | Por que fica |
|---|---|---|---|
| Tópico SNS | sim | não | sem uso, não gera custo residual |
| Filas SQS (3) e DLQ de processamento (3) | sim | não sem mensagem | custo é só por requisição; fila vazia não cobra |
| DLQ de entrega | sim | não sem mensagem | idem — o risco é esquecer de investigar o que ficou dentro |
| Funções Lambda (3) | sim | não | sem invocação, sem cobrança |
| Grupos de log do Lambda | depende de `skip_destroy` | sim, retenção | ciclo próprio; pode sobreviver à função que o alimentava |
| Assinaturas SNS | sim, junto com o tópico ou a fila | não | órfã só se um dos dois lados foi apagado fora de ordem |
Resumo: problema, peça e motivo
| Problema | Peça | Por que ela, e não outra |
|---|---|---|
| Produtor precisa saber quem está ouvindo | tópico SNS único | a lista de destinos sai do código e vira assinatura |
| Consumidor novo exige deploy do produtor | assinatura nova via Terraform | entrar num mapa `for_each` não é o mesmo que editar o método de checkout |
| E-mail duplicado em reprocessamento | filtro de mensagem na assinatura | a fila nunca vê o que não lhe interessa; a decisão é declarativa e auditável |
| Consumidor fora do ar perderia mensagem | fila SQS entre o tópico e a Lambda | durabilidade e visibilidade que uma assinatura direta de Lambda não oferece |
| "Publiquei com sucesso" não significa "entreguei" | DLQ de assinatura, separada da DLQ da fila | as duas causas de falha pedem investigação e correção diferentes |
| Corpo da mensagem embrulhado no envelope do SNS | `RawMessageDelivery = true` | o consumidor lê o JSON publicado direto, sem tradução repetida em cada handler |
| Fila apagada ou política revogada por outro time | política de acesso com `SourceArn` | só este tópico pode escrever nesta fila — fecha a porta que ficaria aberta com `Principal: "*"` |
| Mensagem processada duas vezes (entrega "pelo menos uma vez") | idempotência herdada do L22 | a trava condicional por `pedidoId` já resolvia isso; só foi reaproveitada |
| Falha | O que a protege | O que ela NÃO protege |
|---|---|---|
| Fila sem política de acesso | checagem explícita na prova 1 e na seção de troubleshooting | não previne o erro de configuração — só torna o diagnóstico rápido |
| Consumidor fora do ar perdendo mensagem | fila com `visibility timeout` e retenção própria | consumidor fora do ar por MAIS tempo que a retenção da fila — aí é o L22/nível 5 (replay) |
| Filtro descartando mensagem legítima | prova 2 desta seção, publicando os três valores de `origem` | não protege contra um quarto valor de `origem` criado sem atualizar o filtro |
| Mensagem duplicada processada duas vezes | idempotência do L22, reaproveitada | não protege se um consumidor NOVO esquecer de implementar a mesma trava |
| Ordem entre eventos do mesmo pedido | nenhuma, no tópico standard | nada — é dívida declarada; a resposta é tópico FIFO, não código no consumidor |
- O checkout grava o pedido no Postgres, na mesma transação de sempre.
- A task publica UMA vez no tópico, com o pedido no corpo e atributos de mensagem.
- O SNS avalia o filtro de cada assinatura antes de tentar entregar.
- A fila de estoque recebe sempre — sem filtro — e o consumidor idempotente do L22 processa.
- A fila de e-mail recebe só quando `origem` não é reprocessamento ou correção.
- A fila de analytics — criada só neste laboratório — recebe tudo, sem tocar no checkout.
- Cada fila entrega ao seu consumidor via `event_source_mapping`, em lote.
- Falha de processamento de UM item do lote não reprocessa os outros nove.
- Falha de processamento repetida 5 vezes move a mensagem para a DLQ daquela fila.
- Falha de ENTREGA do SNS para a fila move a mensagem para a DLQ de entrega, separada.
Perguntas frequentes
❓ Por que colocar SQS entre o tópico SNS e o Lambda, em vez de assinar direto?
❓ O SNS entrega as mensagens às filas na mesma ordem em que os pedidos foram criados?
❓ O que muda no código do produtor quando eu adiciono o quarto consumidor?
❓ Preciso mudar o produtor se um consumidor mudar de ideia sobre o que recebe?
❓ Qual a diferença entre a DLQ da fila e a DLQ da assinatura do SNS?
❓ RawMessageDelivery é obrigatório numa assinatura SNS para SQS?
❓ O SNS entrega cada mensagem exatamente uma vez para cada assinante?
❓ Por que não usar EventBridge desde já, já que ele também faz fanout?
Fixando
Uma Lambda recém-assinada a uma fila SQS que recebe mensagens de um tópico SNS começa a lançar exceção de desserialização em toda mensagem processada, embora as mensagens estejam chegando normalmente à fila. Qual é a causa mais provável?
Um `Publish` no tópico `pedido-criado` retorna sucesso com um `MessageId` válido. Nenhuma das três filas assinadas (estoque, e-mail, analytics) mostra mensagem nova, e nenhum filtro de mensagem está configurado em nenhuma das três assinaturas. Qual é a causa mais provável?
Conhecimentos, próximo módulo e documentação
| Item | Conteúdo |
|---|---|
| Conhecimentos anteriores necessários | L21 (Lambda e cold start), L22 (fila SQS com DLQ e consumidor idempotente) no ar |
| Conhecimentos adquiridos | fanout SNS → SQS; filtro de mensagem por assinatura e seu escopo; a distinção entre DLQ de entrega e DLQ de processamento; RawMessageDelivery e o que ele evita; por que SQS entre o tópico e o consumidor; redução de raio de explosão no produtor |
| Limitação que fica | tópico standard não garante ordem nem exclusão de duplicata entre filas — se isso passar a importar, é tópico FIFO, não correção no consumidor |
| Próximo laboratório recomendado | L24 — EventBridge como espinha dorsal. Migra o mesmo problema para múltiplos tipos de evento, com regras de roteamento e schema versionado |
| Também habilitado por este módulo | qualquer laboratório futuro que precise notificar mais de um sistema a partir de um único evento de negócio reaproveita este padrão de fila por consumidor com DLQ própria |
| Data da última validação técnica | 7 de agosto de 2026 |
Documentação oficial consultada: Amazon SNS — Fanout to Amazon SQS queues — o formato do envelope de mensagem, o efeito de `RawMessageDelivery` e a necessidade de política de acesso na fila; Amazon SNS message filtering — o funcionamento do `filter_policy`, seu escopo (atributos ou corpo) e os operadores disponíveis; e Amazon SNS dead-letter queues for subscriptions — por que a DLQ pertence à assinatura, não ao tópico, e o número de retentativas de entrega para endpoints gerenciados pela AWS. 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
O número de retentativas de entrega do SNS para endpoints gerenciados pela AWS (100.015 tentativas em 23 dias) vem da documentação oficial consultada nesta data, não de medição própria — e a AWS pode revisar esses valores. Os volumes de mensagem nas provas desta seção (tempo de chegada às filas, contagem de tentativas até a DLQ) refletem o ambiente de exemplo usado na escrita deste módulo; meça no seu ambiente antes de tratar qualquer número aqui como garantia contratual.
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…