Fila não é entrega: a tela só diz "enviado" quando o trabalho terminou
Separe "recebido" de "concluído" em tarefas assíncronas: grave o estado do pedido, responda 202, confirme no worker e mostre pendente, enviado ou falhou de verdade.
Segunda, 9h. O suporte recebe doze mensagens: "o convite diz que foi enviado, mas não chegou". A tela mostrou "Convite enviado" no instante em que a mensagem entrou na fila. O worker que manda os e-mails estava parado desde o deploy de sexta. A fila guardou tudo direitinho, e a interface afirmou um fato que ainda não tinha acontecido. A resposta curta: quando o trabalho é assíncrono, a resposta imediata é "recebemos", não "feito". Grave o pedido com um estado (pendente), responda 202 Accepted com um endereço para consultar, deixe o worker mudar o estado para enviado ou falhou e mostre isso à pessoa.
A fila resolve o problema de não perder o pedido. Ela não resolve o problema de saber se o pedido foi cumprido. São duas perguntas, e a interface precisa responder a segunda sem fingir.
Por que "entrou na fila" parece sucesso
Publicar numa fila é rápido e quase nunca falha. O código da API faz o publish, recebe um retorno sem erro e devolve 200 com a mensagem de sucesso. No teste local, o worker está rodando, o e-mail sai em meio segundo e ninguém percebe a diferença entre as duas coisas.
Em produção, entre a fila e o efeito existem várias formas de falhar: o worker está fora do ar, o provedor de e-mail recusou, o endereço era inválido, a mensagem foi consumida e o processo morreu no meio. Em todos esses casos a API já disse "enviado". A pessoa confia, espera, e descobre o problema só quando o convidado reclama.
Há ainda um buraco na própria publicação. Na maioria dos clientes, a chamada de publicação retorna antes de o broker confirmar que gravou a mensagem. Se a conexão cair nesse intervalo, nem a fila tem o pedido.
O desenho que diz a verdade
- Grave o pedido no banco com estado
pendenteantes de publicar. A linha é a fonte da verdade; a mensagem na fila é só o aviso de que há trabalho. - Publique com confirmação do broker. No RabbitMQ, use um canal com confirmações de publicação e espere a confirmação antes de responder.
- Responda
202 Acceptedcom o identificador e um endereço de consulta. O texto na tela é "Recebemos. Enviando o convite...". - No worker, confirme a mensagem só depois do efeito. Com
ackmanual, se o processo morrer no meio, o broker entrega de novo. - Atualize o estado para
enviadooufalhou, com o motivo do erro guardado. - Mostre o estado real na interface, consultando o endereço ou recebendo um evento. Falha vem com ação: "Tentar de novo".
- Tenha uma varredura para pendentes antigos. Pedido parado há mais de alguns minutos em
pendenteé alarme ou nova publicação.
A tabela de envios
CREATE TABLE envios (
id bigserial PRIMARY KEY,
email text NOT NULL,
status text NOT NULL DEFAULT 'pendente',
erro text,
criado_em timestamptz NOT NULL DEFAULT now(),
concluido_em timestamptz
);
A API em Node.js com amqplib e pg
const express = require('express');
const amqp = require('amqplib');
const { Pool } = require('pg');
const db = new Pool();
const app = express();
app.use(express.json());
async function main() {
const conexao = await amqp.connect(process.env.AMQP_URL);
const canal = await conexao.createConfirmChannel();
await canal.assertQueue('envios', { durable: true });
app.post('/convites', async (req, res) => {
const { rows } = await db.query(
"INSERT INTO envios (email) VALUES ($1) RETURNING id",
[req.body.email]
);
const id = rows[0].id;
canal.sendToQueue('envios', Buffer.from(JSON.stringify({ id })), { persistent: true });
await canal.waitForConfirms();
res.status(202).location('/envios/' + id).json({ id, status: 'pendente' });
});
app.get('/envios/:id', async (req, res) => {
const { rows } = await db.query(
'SELECT status, erro FROM envios WHERE id = $1',
[req.params.id]
);
if (!rows.length) return res.sendStatus(404);
res.json(rows[0]);
});
app.listen(3000);
}
main();
O waitForConfirms segura a resposta até o broker confirmar que recebeu a mensagem. Se a publicação falhar depois do INSERT, a linha fica em pendente e a varredura do passo 7 a encontra. Para eliminar esse intervalo de vez, o padrão é a caixa de saída (outbox): gravar a mensagem numa tabela na mesma transação do pedido e ter um processo que publica a partir dela.
O worker que confirma depois do efeito
async function worker() {
const conexao = await amqp.connect(process.env.AMQP_URL);
const canal = await conexao.createChannel();
await canal.assertQueue('envios', { durable: true });
canal.prefetch(1);
canal.consume('envios', async (msg) => {
const { id } = JSON.parse(msg.content.toString());
const { rows } = await db.query(
"UPDATE envios SET status = 'processando' WHERE id = $1 AND status <> 'enviado' RETURNING email",
[id]
);
if (!rows.length) return canal.ack(msg);
try {
await enviarEmail(rows[0].email);
await db.query(
"UPDATE envios SET status = 'enviado', concluido_em = now() WHERE id = $1",
[id]
);
} catch (err) {
await db.query(
"UPDATE envios SET status = 'falhou', erro = $2 WHERE id = $1",
[id, err.message]
);
}
canal.ack(msg);
}, { noAck: false });
}
O UPDATE ... AND status <> 'enviado' protege contra a reentrega: se o worker mandou o e-mail e morreu antes do ack, o broker entrega de novo, mas o pedido já está como enviado e é ignorado. Ainda existe uma janela pequena (e-mail saiu, processo morreu antes do segundo UPDATE) em que a reentrega pode duplicar. Para e-mail de convite, um duplicado raro costuma ser aceitável. Para cobrança, não: aí entra a chave de idempotência.
A tela que acompanha o estado
async function acompanhar(id, aviso) {
aviso.textContent = 'Recebemos. Enviando o convite...';
const resposta = await fetch('/envios/' + id);
const envio = await resposta.json();
if (envio.status === 'enviado') {
aviso.textContent = 'Convite enviado.';
} else if (envio.status === 'falhou') {
aviso.textContent = 'Não conseguimos enviar o convite. Confira o endereço e tente de novo.';
} else {
setTimeout(() => acompanhar(id, aviso), 2000);
}
}
Coloque o elemento aviso numa região com role="status" para que leitores de tela anunciem as mudanças. Consultar a cada dois segundos é simples e suficiente para tarefas curtas; para tarefas longas, aumente o intervalo ou use eventos do servidor. Se a pessoa sair da tela, nada se perde: o estado está no banco e pode aparecer numa lista de "convites enviados".
Erros comuns
- Responder "enviado" logo após publicar. Qualquer falha posterior vira mentira na tela e chamado no suporte.
- Usar
ackautomático no consumidor. A mensagem sai da fila na entrega; se o worker morrer no meio, o pedido some sem rastro. - Não guardar o estado em lugar nenhum. Sem a linha no banco, ninguém consegue responder "o meu convite saiu?" nem reprocessar falhas.
- Reentregar sem checar se já foi feito. A mesma mensagem processada duas vezes manda dois e-mails, ou cobra duas vezes.
- Falha sem saída para a pessoa. "Erro" sem botão de tentar de novo faz a pessoa refazer o formulário inteiro.
- Nenhum alarme para fila parada. Se o worker cair, a fila cresce em silêncio. Monitore o tamanho da fila e a idade do pendente mais antigo.
Checklist
- A resposta imediata a uma tarefa assíncrona diz "recebido", não "concluído".
- O pedido tem uma linha com estado antes de ir para a fila.
- A publicação espera a confirmação do broker.
- O worker confirma a mensagem só depois de gravar o resultado.
- Reprocessar a mesma mensagem não repete o efeito.
- A interface mostra pendente, concluído e falhou, e a falha tem ação.
- Existe alarme para pendentes com mais de alguns minutos.
- Os logs da API e do worker compartilham o mesmo identificador; veja um identificador por request.
Pare o worker no ambiente de homologação e dispare a ação pela tela. O que a pessoa vê depois de um minuto: "enviado" ou "ainda enviando"?
Referência: https://www.rabbitmq.com/tutorials