Programação Por Equipe DevBras

Capa do artigo: Fila não é entrega: a tela só diz "enviado" quando o trabalho terminou

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

  1. Grave o pedido no banco com estado pendente antes de publicar. A linha é a fonte da verdade; a mensagem na fila é só o aviso de que há trabalho.
  2. 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.
  3. Responda 202 Accepted com o identificador e um endereço de consulta. O texto na tela é "Recebemos. Enviando o convite...".
  4. No worker, confirme a mensagem só depois do efeito. Com ack manual, se o processo morrer no meio, o broker entrega de novo.
  5. Atualize o estado para enviado ou falhou, com o motivo do erro guardado.
  6. Mostre o estado real na interface, consultando o endereço ou recebendo um evento. Falha vem com ação: "Tentar de novo".
  7. 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 ack automá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"?

Avalie este artigo

Referência: https://www.rabbitmq.com/tutorials