Dia 12 — Trabalho que demora

Informatica · Conteudo · publicado em 30/09/2026
Dia 12 de 15

Trabalho que demora

Aula 1

Por que a requisição não pode fazer tudo

Onde a requisição morre

Toda requisição tem um tempo limite, e a operação lenta é o que bate nele. Uma requisição não é uma conexão que fica aberta até o servidor terminar. Ela atravessa quatro elos, e cada elo tem um limite próprio — e nenhum deles avisa os outros que desistiu.

EloOnde o limite é escritoO que acontece quando estoura
navegadorfetch(url, { signal: AbortSignal.timeout(3000) })TimeoutError, a tela fica esperando
proxynginx: proxy_read_timeout 60s504 Gateway Timeout
Nodeserver.requestTimeout, server.headersTimeoutfecha o socket sem responder
MySQLmax_execution_time por consultaaborta a consulta e devolve erro

Esse timeout do cliente é o primeiro nome do problema, e o 504 do proxy é o segundo: são o mesmo corte visto de dois lugares. O limite que estoura é o mais próximo de quem pediu, e é por isso que a mesma requisição falha de formas diferentes conforme onde está o nginx. O detalhe perigoso é o contrário: quando um elo desiste, o Node não é avisado. Ele termina o que estava fazendo, gasta CPU e consulta MySQL de um trabalho que ninguém vai ler.

AbortSignal.timeout(ms) é o objeto que transforma isso em código, e ele existe tanto no navegador quanto no Node:

const controle = AbortSignal.timeout(3000);
try {
  const resposta = await fetch(url, { signal: controle });
} catch (erro) {
  // erro.name e TimeoutError: e o mesmo mecanismo dos dois lados
  console.log(erro.name);
}

O exemplo usa AbortSignal.timeout do lado do cliente e uma rota lenta do lado do servidor: a requisição é abortada antes da resposta, e o handler continua rodando até o fim de qualquer jeito. É a prova de que a desistencia do cliente não cancela o servidor.

O caminho errado: o servidor faz tudo antes de responder

Uma requisição demorada não é uma requisição lenta: é uma requisição travada no que o cliente está esperando. O sintoma é o o cliente espera na tela enquanto o servidor trabalha.

A requisição trava no instante em que o handler entra num await que ninguém está contando. Não é lentidão difusa: é um ponto parado, e enquanto o await não volta aquele processo não atende mais ninguém. O handler mais intuitivo do mundo grava o pedido e só depois faz o trabalho pesado:

async function caminhoNaHora(c, corpo) {
  const [r] = await c.execute(
    'INSERT INTO tb_d12a1_pedido (st_cliente, st_canal) VALUES (?, ?)',
    [corpo.cliente, 'na-hora']);
  await trabalhoPesado(c, r.insertId);   // e-mail e PDF antes de responder
  return r.insertId;
}

A gravação do pedido custa alguns milissegundos; o resto é trabalho que o cliente não pediu para esperar. Se o e-mail leva 3 segundos e o cliente já desistiu no segundo 1, esses 3 segundos foram jogados fora — e foram os 3 segundos mais caros do servidor, os que ele não podia gastar atendendo outra pessoa.

O exemplo mede com performance.now() em volta de cada etapa e imprime a divisão: quanto tempo foi para gravar no banco, quanto foi para o trabalho pesado, e quanto foi a resposta. O que importa na leitura do exemplo não é o número absoluto — ele muda a cada execução, porque a máquina de quem roda é outra — e sim a proporção: a resposta custa quase o mesmo que o trabalho pesado.

O que não muda em nenhum caminho: a consulta de INSERT. Se a resposta só depende do que foi gravado, ela sai no tempo de uma consulta de banco, e não no tempo do serviço de e-mail.

O caminho certo: responder e depois trabalhar

O caminho certo não é "fazer mais rápido". É trocar a ordem: grava o que é preciso, responde, e termina depois.

async function caminhoRapido(c, corpo) {
  const [r] = await c.execute(
    'INSERT INTO tb_d12a1_pedido (st_cliente, st_canal) VALUES (?, ?)',
    [corpo.cliente, 'rapido']);
  setTimeout(() => {
    trabalhoPesado(c, r.insertId);   // fora da requisicao
  }, 0);
  return r.insertId;
}

setTimeout(..., 0) devolve o controle do event loop: o handler termina, a resposta sai, e o trabalho pesado roda depois, sem ninguém esperando. O exemplo chama os dois caminhos com o mesmo trabalho pesado — mesmas esperas, mesma ordem — e muda só o QUANDO.

O que o exemplo mostra, e vale mais que o número:

  • a resposta do caminho rápido custa o tempo de uma consulta, e o trabalho pesado aparece como 0 ms no instante em que a resposta chegou — porque ele ainda nem começou;
  • logo depois, o registro do trabalho pesado aparece no banco, sem que ninguém tenha pedido;
  • e o pedido cujo cliente desistiu mesmo assim aparece completo no banco: o pedido existe, o e-mail foi enviado, o PDF foi gerado, e nenhum desses três resultados chegou em alguém.

Esse setTimeout é o caminho certo e a solução incompleta. Se o processo reiniciar entre o INSERT e o setTimeout — um deploy, um pm2 restart, um corte de energia — o trabalho pesado simplesmente não acontece, e o cliente recebeu um 200 de um e-mail que nunca existiu. O que falta é persistir a intenção: gravar que o trabalho precisa ser feito e ter alguém que vá buscá-lo. É a fila, e é a aula 2.

O que precisa ser síncrono e o que pode ser assíncrono

A divisão não é "coisa lenta" contra "coisa rápida" — é o que o cliente precisa ver para considerar a operação feita.

OperaçãoSincrono ou assíncronoPor quê
gravar o pedidosíncronoo cliente precisa saber que o pedido existe, e o id volta na resposta
cobrar o pagamentosíncronoa resposta confirma a cobrança; devolver antes é dar o produto de graça
validar a senhasíncronoa resposta é o sim ou o não; não há o que fazer depois
e-mail de confirmaçãoassíncronoo cliente não lê o e-mail antes de voltar para a tela
gerar PDF do relatórioassíncronoo arquivo vai ser acessado depois, e o painel avisa que está pronto
redimensionar imagemassíncronoo upload já devolveu; o tamanho final é detalhe
importar arquivo grandeassíncronoleva minutos; a resposta diz "em andamento" e o trabalho segue

O erro comum é tratar tudo como síncrono por simetria do código — o await já está ali, então fica mais fácil. E o custo é o mesmo dos 3 segundos jogados fora: capacidade de servidor. Um relatório que leva 20 segundos segurando a requisição é 20 segundos em que aquele processo não atende mais ninguém.

Nos dois caminhos o status é 200 e o id está na resposta. O que muda não é o que o cliente recebe — é quanto tempo ele fica esperando por isso, e o que o servidor faz nos segundos seguintes.

"Processar na mesma hora" e "processar depois" não são a mesma coisa com nomes diferentes. A primeira prende a requisição; a segunda prende o trabalho, e trabalho preso precisa de um lugar para ficar. Esse lugar é a fila.

Um 202 Accepted existe para o caminho assíncrono: ele diz "recebi, ainda não terminei". Devolver 200 e esconder o trabalho em um setTimeout é a versão sem contrato do mesmo caminho — funciona até o primeiro restart, e o restart é certo.

O limite do cliente não é um problema do cliente. Um AbortSignal.timeout grande demais só transfere o problema para o proxy, que devolve 504 — e o usuário vê um erro que não diz nada. O que a fila resolve é o servidor parando de trabalhar para um cliente que já foi embora.

Exemplo

'use strict';

// Exemplo da aula 1 do dia 12: por que a requisicao nao pode fazer tudo.
//
// A cadeia de uma requisicao tem quatro elos, e CADA elo tem um limite
// proprio. O limite que estoura e sempre o mais proximo de quem pediu, e o
// elo que desistiu nao avisa os outros: o Node continua trabalhando depois
// que o navegador ja desistiu, e as vezes trabalhando em algo que nao
// importa mais.
//
// O exemplo compara os dois caminhos com o MESMO trabalho pesado:
//   1. `/pedido/na-hora`  grava o pedido, faz o trabalho pesado e so entao
//                         responde — trabalho antes de responder
//   2. `/pedido/rapido`   grava o pedido, responde na hora e faz o trabalho
//                         pesado DEPOIS, fora da requisicao
//
// TEMPO REDUZIDO DE PROPOSITO: as esperas deste arquivo sao de 50 a 300 ms
// porque o exemplo roda com limite de 45 s. Num sistema real o e-mail de
// confirmacao leva segundos e o PDF de uma relatorio leva muito mais: a
// diferenca entre os dois caminhos e a mesma, so que ela aparece em
// segundos e nao em milissegundos.

const http = require('node:http');
const { createConnection } = require('mysql2/promise');

// ============================================================ 1. a cadeia
// Cada elo tem um nome proprio para o limite dele, e nenhum deles e o mesmo
// do outro. O `AbortSignal.timeout` e do navegador, o `proxy_read_timeout` e
// do nginx, e o MySQL tem dois limites que confundem: `wait_timeout` (a
// conexao parada) e `max_execution_time` (uma consulta que nao termina).
function limitesDaCadeia() {
  return [
    ['navegador', 'fetch(url, { signal: AbortSignal.timeout(3000) })', 'a aba fecha, o token expira'],
    ['proxy', 'nginx: proxy_read_timeout 60s', 'devolve 504 Gateway Timeout'],
    ['Node', 'server.requestTimeout / headersTimeout', 'fecha o socket sem responder'],
    ['MySQL', 'max_execution_time por consulta', 'aborta a consulta e devolve erro'],
  ];
}

// ======================================================== 2. o trabalho pesado
// Isolar o trabalho pesado numa funcao e o que torna a comparacao justa: os
// dois caminhos executam EXATAMENTE estas esperas. O que muda e so QUANDO.
async function trabalhoPesado(c, idPedido) {
  await esperar(150);            // enviar e-mail de confirmacao
  await esperar(150);            // gerar PDF do comprovante
  await c.execute(
    'INSERT INTO tb_d12a1_fundo (id_pedido, st_etapa) VALUES (?, ?)',
    [idPedido, 'e-mail de confirmacao enviado e PDF gerado']);
  return performance.now();
}

const esperar = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// =========================================================== 3. os caminhos
// Caminho 1: o trabalho pesado ANTES de responder. O cliente espera tudo.
async function caminhoNaHora(c, corpo) {
  const inicio = performance.now();

  const [r] = await c.execute(
    'INSERT INTO tb_d12a1_pedido (st_cliente, st_canal) VALUES (?, ?)',
    [corpo.cliente, 'na-hora']);
  const idPedido = r.insertId;
  const msGravacao = performance.now() - inicio;

  await trabalhoPesado(c, idPedido);

  return { idPedido, msGravacao, msTotal: performance.now() - inicio };
}

// Caminho 2: responder rapido e terminar depois. A resposta nao espera o
// trabalho pesado, e por isso ela sai em tempo de consulta de banco.
async function caminhoRapido(c, corpo) {
  const inicio = performance.now();

  const [r] = await c.execute(
    'INSERT INTO tb_d12a1_pedido (st_cliente, st_canal) VALUES (?, ?)',
    [corpo.cliente, 'rapido']);
  const idPedido = r.insertId;

  // O `setTimeout` com 0 ms devolve o controle do event loop: o handler
  // termina, a resposta sai, e o trabalho pesado roda DEPOIS, fora da
  // requisicao. Nao e fila ainda — e fila e assunto da aula 2 — mas ja e o
  // caminho certo, e mede a diferenca.
  setTimeout(() => {
    trabalhoPesado(c, idPedido).catch((erro) => {
      console.error('trabalho de fundo falhou: ' + erro.code + ' - ' + erro.message);
      console.log('  trabalho de fundo falhou:', erro.code, '-', erro.message);
    });
  }, 0);

  return { idPedido, msTotal: performance.now() - inicio };
}

async function main() {
  const c = await createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
    multipleStatements: true,
  });

  await c.query(`
    CREATE TABLE IF NOT EXISTS tb_d12a1_pedido (
      id          INT AUTO_INCREMENT PRIMARY KEY,
      st_cliente  VARCHAR(40) NOT NULL,
      st_canal    VARCHAR(12) NOT NULL,
      dt_criacao  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);
  await c.query(`
    CREATE TABLE IF NOT EXISTS tb_d12a1_fundo (
      id           INT AUTO_INCREMENT PRIMARY KEY,
      id_pedido    INT NOT NULL,
      st_etapa     VARCHAR(60) NOT NULL,
      dt_registro  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);

  // O prefixo `tb_d12a1_` isola a tabela das outras aulas: `IF NOT EXISTS`
  // NAO corrige esquema, e o banco de teste ja tem `tb_pedido` de outro dia
  // com outra forma. O exemplo roda varias vezes seguidas, e o `TRUNCATE` no
  // comeco garante que a segunda execucao comece igual a primeira.
  await c.query('TRUNCATE TABLE tb_d12a1_pedido');
  await c.query('TRUNCATE TABLE tb_d12a1_fundo');

  console.log('--- 1. a cadeia e o limite de cada elo ---');
  for (const [elo, limite, oQueAcontece] of limitesDaCadeia()) {
    console.log('  ' + elo.padEnd(10) + limite.padEnd(52) + oQueAcontece);
  }
  console.log('o limite que estoura primeiro e o elo mais proximo de quem pediu;');
  console.log('o Node nao recebe o aviso da desistencia e segue trabalhando');

  // ------------------------------------------------------------ o servidor
  const rotas = {
    '/pedido/na-hora': (corpo) => caminhoNaHora(c, corpo),
    '/pedido/rapido': (corpo) => caminhoRapido(c, corpo),
  };

  const servidor = http.createServer(async (req, res) => {
    const partes = [];
    for await (const p of req) partes.push(p);
    const corpo = JSON.parse(Buffer.concat(partes).toString('utf8') || '{}');
    const rota = rotas[req.url];

    if (!rota) {
      res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
      return res.end(JSON.stringify({ erro: 'rota nao encontrada' }));
    }

    try {
      const resposta = await rota(corpo);
      res.writeHead(resposta.msTotal > 1000 ? 504 : 200,
        { 'Content-Type': 'application/json; charset=utf-8' });
      res.end(JSON.stringify(resposta));
    } catch (erro) {
      console.error('rota falhou: ' + erro.code + ' - ' + erro.message);
      console.log('  rota falhou:', erro.code, '-', erro.message);
      res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
      res.end(JSON.stringify({ erro: erro.code || erro.name }));
    }
  });

  await new Promise((resolve) => servidor.listen(0, '127.0.0.1', resolve));
  const base = 'http://127.0.0.1:' + servidor.address().port;
  console.log('\nservidor no ar em ' + base);
  console.log('a porta muda a cada execucao: e o listen(0) pedindo uma livre');

  // `limiteMs` e o `AbortSignal.timeout` do lado do cliente — e o mesmo
  // mecanismo que o `fetch` do navegador usa. Sem ele nao ha como provar que o
  // cliente desiste antes da resposta.
  const pedir = (caminho, corpo, limiteMs) => fetch(base + caminho, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(corpo),
    signal: limiteMs ? AbortSignal.timeout(limiteMs) : undefined,
  });

  try {
    // ------------------------------------------------- trabalho ANTES de responder
    console.log('\n--- 2. caminho 1: o servidor faz tudo ANTES de responder ---');
    const inicioLento = performance.now();
    const lento = await pedir('/pedido/na-hora', { cliente: 'ana' });
    const corpoLento = await lento.json();
    const msLento = performance.now() - inicioLento;

    console.log('pedido ' + corpoLento.idPedido + '  status ' + lento.status
      + '  | cliente esperou ' + msLento.toFixed(0) + ' ms');
    console.log('  gravacao no banco .... ' + corpoLento.msGravacao.toFixed(0) + ' ms');
    console.log('  e-mail + PDF ......... ' + (corpoLento.msTotal - corpoLento.msGravacao).toFixed(0) + ' ms');
    console.log('  resposta .............. ' + corpoLento.msTotal.toFixed(0) + ' ms');
    console.log('o cliente esperou o e-mail de confirmacao e o PDF, sem precisar de nada disso');

    // ------------------------------------------------- responder ANTES de trabalhar
    console.log('\n--- 3. caminho 2: responder rapido e terminar depois ---');
    const inicioRapido = performance.now();
    const rapido = await pedir('/pedido/rapido', { cliente: 'bruno' });
    const corpoRapido = await rapido.json();
    const msRapido = performance.now() - inicioRapido;

    console.log('pedido ' + corpoRapido.idPedido + '  status ' + rapido.status
      + '  | cliente esperou ' + msRapido.toFixed(0) + ' ms');
    console.log('  gravacao do pedido ... ' + corpoRapido.msTotal.toFixed(0) + ' ms');
    console.log('  e-mail + PDF ......... 0 ms (ainda nao rodou)');
    console.log('mesmo trabalho pesado nos dois caminhos: o cliente esperou '
      + msLento.toFixed(0) + ' ms no primeiro e ' + msRapido.toFixed(0) + ' ms no segundo');
    console.log('os dois numeros sao medidos de verdade e mudam a cada execucao:');
    console.log('o que fica e a ordem de grandeza, nao o valor');
    console.log('o pedido ja esta gravado: e isso que o cliente precisa, e nao o PDF');

    // O cliente segue vivo sem o PDF. O trabalho de fundo continua no servidor.
    const [antes] = await c.query(
      'SELECT COUNT(*) AS n FROM tb_d12a1_fundo WHERE id_pedido = ?',
      [corpoRapido.idPedido]);
    console.log('\nno instante em que a resposta chegou, o trabalho pesado tinha '
      + 'gravado ' + antes[0].n + ' registro(s)');
    console.log('o servidor segue trabalhando sem ninguem esperando a resposta');

    // ------------------------------------------------------------- o cliente desiste
    console.log('\n--- 4. o cliente desiste antes da resposta ---');
    console.log('vou pedir o caminho lento com um limite de 120 ms no cliente');
    try {
      await pedir('/pedido/na-hora', { cliente: 'clara' }, 120);
      console.log('a resposta chegou antes do limite — nao era o que o exemplo esperava');
    } catch (erro) {
      console.error('cliente desistiu: ' + erro.name + ' - ' + erro.message);
      console.log('  erro no cliente:', erro.name, '-', erro.message);
      console.log('  o AbortSignal.timeout(120) estourou: o fetch foi interrompido');
      console.log('  o navegador teria mostrado TimeoutError e o proxy um 504');
    }

    // O servidor nao sabe da desistencia: ele termina o trabalho mesmo assim.
    await esperar(500);
    const [depois] = await c.query(
      'SELECT COUNT(*) AS n FROM tb_d12a1_fundo WHERE id_pedido = 3');
    console.log('\no pedido do cliente que desistiu: ' + depois[0].n
      + ' registro(s) de trabalho pesado gravados pelo servidor');
    console.log('o pedido existe, o e-mail foi enviado e o PDF foi gerado');
    console.log('e nenhum desses tres resultados chegou em ninguem — o trabalho foi jogado fora');

    // ------------------------------------------------------------- o que o banco tem
    console.log('\n--- 5. o que o banco tem depois das tres requisicoes ---');
    const [pedidos] = await c.query(
      'SELECT id, st_cliente, st_canal FROM tb_d12a1_pedido ORDER BY id');
    for (const p of pedidos) {
      console.log('  id ' + p.id + '  ' + p.st_cliente.padEnd(7) + ' ' + p.st_canal);
    }
    const [fundos] = await c.query(
      'SELECT id_pedido, st_etapa FROM tb_d12a1_fundo ORDER BY id');
    console.log('  trabalho pesado concluido para: '
      + fundos.map((f) => 'pedido ' + f.id_pedido).join(', '));
    console.log('tres pedidos gravados e tres trabalhos pesados concluidos');
    console.log('a unica diferenca entre os caminhos foi QUANDO a resposta saiu');
  } finally {
    await new Promise((resolve) => servidor.close(resolve));
    console.log('\nservidor encerrado com close().');
    await c.end();
  }
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

--- 1. a cadeia e o limite de cada elo ---
  navegador fetch(url, { signal: AbortSignal.timeout(3000) })   a aba fecha, o token expira
  proxy     nginx: proxy_read_timeout 60s                       devolve 504 Gateway Timeout
  Node      server.requestTimeout / headersTimeout              fecha o socket sem responder
  MySQL     max_execution_time por consulta                     aborta a consulta e devolve erro
o limite que estoura primeiro e o elo mais proximo de quem pediu;
o Node nao recebe o aviso da desistencia e segue trabalhando

servidor no ar em http://127.0.0.1:46461
a porta muda a cada execucao: e o listen(0) pedindo uma livre

--- 2. caminho 1: o servidor faz tudo ANTES de responder ---
pedido 1  status 200  | cliente esperou 423 ms
  gravacao no banco .... 8 ms
  e-mail + PDF ......... 311 ms
  resposta .............. 319 ms
o cliente esperou o e-mail de confirmacao e o PDF, sem precisar de nada disso

--- 3. caminho 2: responder rapido e terminar depois ---
pedido 2  status 200  | cliente esperou 13 ms
  gravacao do pedido ... 6 ms
  e-mail + PDF ......... 0 ms (ainda nao rodou)
mesmo trabalho pesado nos dois caminhos: o cliente esperou 423 ms no primeiro e 13 ms no segundo
os dois numeros sao medidos de verdade e mudam a cada execucao:
o que fica e a ordem de grandeza, nao o valor
o pedido ja esta gravado: e isso que o cliente precisa, e nao o PDF

no instante em que a resposta chegou, o trabalho pesado tinha gravado 0 registro(s)
o servidor segue trabalhando sem ninguem esperando a resposta

--- 4. o cliente desiste antes da resposta ---
vou pedir o caminho lento com um limite de 120 ms no cliente
  erro no cliente: TimeoutError - The operation was aborted due to timeout
  o AbortSignal.timeout(120) estourou: o fetch foi interrompido
  o navegador teria mostrado TimeoutError e o proxy um 504

o pedido do cliente que desistiu: 1 registro(s) de trabalho pesado gravados pelo servidor
o pedido existe, o e-mail foi enviado e o PDF foi gerado
e nenhum desses tres resultados chegou em ninguem — o trabalho foi jogado fora

--- 5. o que o banco tem depois das tres requisicoes ---
  id 1  ana     na-hora
  id 2  bruno   rapido
  id 3  clara   na-hora
  trabalho pesado concluido para: pedido 1, pedido 2, pedido 3
tres pedidos gravados e tres trabalhos pesados concluidos
a unica diferenca entre os caminhos foi QUANDO a resposta saiu

servidor encerrado com close().
Aula 2

Fila de trabalho e execução em segundo plano

A tabela como fila

Fila — queue, no termo em inglês — é o que permite processar fora da requisição. Não é Redis, não é RabbitMQ: uma fila com as propriedades que importam cabe em uma tabela do MySQL, e cabe porque o MySQL já é o lugar onde as coisas precisam sobreviver a um restart.

O padrão clássico tem uma tabela de jobs, sete colunas e um INSERT:

CREATE TABLE IF NOT EXISTS tb_d12a2_trabalho (
  id               INT AUTO_INCREMENT PRIMARY KEY,
  st_tipo          VARCHAR(30) NOT NULL,
  ds_payload       VARCHAR(200) NOT NULL,
  st_status        VARCHAR(12) NOT NULL DEFAULT 'pendente',
  dt_criacao       DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  dt_processamento DATETIME NULL,
  nr_tentativas    INT NOT NULL DEFAULT 0
)

Os nomes seguem a convenção do material: st_ para status, dt_ para data, nr_ para número, ds_ para descrição. ds_payload é JSON em texto, e é isso que permite a um único worker atender e-mail, imagem e qualquer tipo novo sem mudar o schema.

CREATE TABLE IF NOT EXISTS é o que faz o exemplo rodar duas vezes sem erro. O índice composto é o que torna o SELECT do worker barato — ele filtra por st_status e ordena por id:

CREATE INDEX ix_d12a2_status ON tb_d12a2_trabalho (st_status, id);

O IF NOT EXISTS do MySQL não cobre índice, e o exemplo engole o erro ER_DUP_KEYNAME quando o índice já existe, porque rodar duas vezes é requisito, não exceção.

INSERT enfileira

Cada linha da tabela é um job — a tarefa de fundo que alguém precisa fazer. Enfileirar é um INSERT e nada mais:

async function enfileirar(c, tipo, payload) {
  const [r] = await c.execute(
    'INSERT INTO tb_d12a2_trabalho (st_tipo, ds_payload) VALUES (?, ?)',
    [tipo, JSON.stringify(payload)]);
  return r.insertId;
}

O que muda é onde isso roda. Na aula 1 o INSERT do pedido estava dentro do handler e o trabalho pesado vinha logo depois. Agora o INSERT da fila é a última coisa que a requisição faz: grava que o trabalho precisa ser feito, e responde.

É a diferença entre processar na mesma hora e processar depois. A primeira prende a requisição. A segunda prende o trabalho, e trabalho preso precisa de um lugar para ficar.

O resultado é que a requisição responde rápido — e a medida disso é simples: quanto tempo passa entre o INSERT da fila e o envio da resposta. Não há e-mail nem PDF esperando, então sobra o tempo de uma consulta de banco, e é o mesmo tempo de quando o trabalho não existia.

O INSERT da fila é a última coisa que a requisição faz, e o que vem depois é trabalho de fundo que sobrevive ao restart. Um setTimeout da aula 1 não sobrevive — ele morre com o processo, e o cliente levou um 200 de um e-mail que nunca existiu.

O worker consome: SELECT, marca, processa, marca

O ciclo inteiro são quatro comandos, e a ordem deles é o que faz a fila funcionar:

async function pegarTrabalho(c) {
  const [linhas] = await c.query(
    "SELECT id, st_tipo, ds_payload FROM tb_d12a2_trabalho "
    + "WHERE st_status = 'pendente' ORDER BY id LIMIT 1");
  if (linhas.length === 0) return null;

  const trabalho = linhas[0];
  const [marca] = await c.execute(
    "UPDATE tb_d12a2_trabalho SET st_status = 'processando', "
    + 'dt_processamento = NOW() '
    + "WHERE id = ? AND st_status = 'pendente'", [trabalho.id]);

  if (marca.affectedRows === 0) return null;   // outro worker chegou antes
  return { ...trabalho, payload: JSON.parse(trabalho.ds_payload) };
}

Quatro pontos desse código respondem perguntas que aparecem em produção:

  • ORDER BY id LIMIT 1 é o que garante ordem: o mais antigo primeiro. Sem o ORDER BY, o MySQL devolve a linha na ordem que lhe for mais barata, e a fila vira lote aleatório.
  • LIMIT 1 é o que impede o worker de carregar a fila inteira na memória. Ele pega uma, termina, e pega a próxima.
  • marcar processando ANTES de processar é a trava. Sem isso, dois workers chamam o SELECT no mesmo instante, os dois recebem a mesma linha, e o mesmo cliente é cobrado duas vezes.
  • WHERE st_status = 'pendente' no UPDATE é o que fecha o caso dos dois workers. O segundo recebe affectedRows = 0, sabe que o trabalho não é dele, e segue para o próximo. Não é SELECT ... FOR UPDATE nem transação: é UPDATE condicional, e a condição faz o papel da trava.

O throw no fim do ciclo marca feito ou erro e soma nr_tentativas:

async function terminar(c, id, status) {
  await c.execute(
    'UPDATE tb_d12a2_trabalho SET st_status = ?, '
    + 'nr_tentativas = nr_tentativas + 1 WHERE id = ?', [status, id]);
}

O nr_tentativas soma aqui, no fim do ciclo, e não na marcação de processando. A diferença importa: marcar é pegar, terminar é tentar. Quem pergunta "quantas vezes isso já foi tentado?" precisa da segunda contagem, e é por isso que a coluna existe — é o dado que decide se vale tentar de novo (retry) ou se o trabalho foi recusado de vez.

O st_status é o que dá status do job a quem olha o painel, e a pergunta que ele responde é sempre a mesma: isso ainda está em algum lugar, ou se perdeu? Uma fila sem st_status vira uma tabela de coisas prontas que ninguém sabe se terminou.

Os três defeitos de quem não olha a fila

O worker morre no meio. O trabalho fica em processando e para sempre: o SELECT só olha pendente, então nenhum worker novo vai pegá-lo. O sintoma é um processando com duas horas de data. O resgate compara a data e devolve para a fila:

async function resgatarPresos(c, limiteMinutos) {
  const [r] = await c.execute(
    "UPDATE tb_d12a2_trabalho SET st_status = 'pendente', "
    + 'dt_processamento = NULL '
    + "WHERE st_status = 'processando' "
    + 'AND dt_processamento < DATE_SUB(NOW(), INTERVAL ? MINUTE)',
    [limiteMinutos]);
  return r.affectedRows;
}

Por isso dt_processamento existe separada de dt_criacao: dt_criacao diz há quanto tempo o trabalho está esperando, e dt_processamento diz há quanto tempo algum worker está com ele. São perguntas diferentes, e misturá-las faz o resgate devolver trabalho que ainda está sendo executado.

A fila não é idempotente. Processar duas vezes não pode cobrar o cliente duas vezes. A marcação de processando evita os dois workers pegando ao mesmo tempo, mas não evita o worker morrer depois de cobrar e antes de marcar feito — e o resgate vai reprocessar. O que protege é o efeito idempotente: a cobrança carrega um id do trabalho, e o segundo processamento encontra o registro e não cobra de novo. Marcar st_status antes do trabalho evita a corrida; só a idempotência do efeito evita a cobrança dobrada depois de um crash.

O erro fica na fila para sempre. Sem st_status = 'erro', o worker pega o trabalho, falha, marca processando de novo e tenta para sempre — o mesmo e-mail com credencial expirada sai em loop, e cada tentativa gasta tempo de servidor. Marcar erro tira o trabalho da frente do worker, e o nr_tentativas é o que responde se aquilo já foi tentado vezes demais. Trabalho em erro é para decisão humana, e a decisão é reverter para pendente (a retentativa) ou marcar como descartado.

Onde o setTimeout da aula 1 vira worker

A diferença entre os dois dias, em uma linha cada:

Aula 1Aula 2
quem guarda o trabalhoninguéma tabela tb_d12a2_trabalho
se o processo reiniciao trabalho se perdeo trabalho continua na fila
se dois workers pegam o mesmonão existe esse casoa marcação resolve
se o worker morreo trabalho se perdeo resgate devolve para pendente

O setTimeout da aula 1 roda dentro do processo da requisição, e um processo que reinicia não tem memória nenhuma do que ia fazer. A fila é a mesma intenção, persistida em uma tabela que o próximo processo vai ler.

Fila no MySQL não é a única opção e não é a mais rápida. Redis tem BRPOPLPUSH, que tira e devolve a linha em uma operacao so, sem UPDATE condicional, e RabbitMQ entrega confirmação do consumidor. A vantagem da tabela é que ela já está lá, tem transação com o resto dos dados, e quem olha o banco enxerga a fila no mesmo lugar do resto.

A diferença entre setImmediate e setTimeout aqui é de quando, não de onde. setTimeout agenda para depois de um tempo; setImmediate roda no próximo ciclo do event loop. Nenhum dos dois sobrevive ao processo, e nenhum dos dois registra que o trabalho precisa ser feito — os dois resolvem só a ordem dentro do mesmo processo.

Um DELETE FROM tb_d12a2_trabalho WHERE st_status = 'feito' periódico não pode ser o mesmo DELETE do começo do exemplo: um zera o AUTO_INCREMENT e o outro não. É por isso que o exemplo usa TRUNCATE para recomeçar do zero — assim a saída embutida diz id 1 numa execução e id 1 na outra.

Exemplo

'use strict';

// Exemplo da aula 2 do dia 12: fila de trabalho e execucao em segundo plano.
//
// A tabela `tb_d12a2_trabalho` E a fila. Nao ha Redis, nao ha RabbitMQ e nao
// ha biblioteca: o `INSERT` enfileira, o `SELECT` pega o trabalho mais antigo
// e o `UPDATE` marca o estado. E o padrao classico, e ele funciona porque o
// proprio MySQL ja e o lugar onde as coisas precisam sobreviver a um restart.
//
// O detalhe que faz a fila funcionar e UM: marcar `st_status = 'processando'`
// ANTES de processar. Sem isso, dois worker chamam o `SELECT` no mesmo
// instante, os dois recebem a mesma linha e o mesmo cliente e cobrado duas
// vezes. A marcacao e a trava, e ela acontece antes do trabalho — nao depois.
//
// TEMPO REDUZIDO DE PROPOSITO: o atraso de cada trabalho e de 60 a 120 ms
// porque o exemplo roda com limite de 45 s. Num sistema real o e-mail leva
// segundos; a mecanica do ciclo — enfileirar, pegar, marcar, terminar — e a
// mesma.

const { createConnection } = require('mysql2/promise');

const esperar = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// ================================================================= a fila
// Quatro estados, e cada um diz uma coisa sobre quem tem o trabalho agora:
//   pendente    — na fila, esperando worker
//   processando — algum worker pegou; se esse worker morrer, fica preso
//   feito       — terminou, e o efeito existe
//   erro        — terminou errando; `nr_tentativas` diz quantas vezes tentou
//
// `dt_criacao` e `dt_processamento` sao as duas datas que o painel precisa: a
// primeira diz ha quanto tempo o trabalho esta esperando, e a segunda existe
// para que um `processando` velho possa ser devolvido para a fila.
async function criarFila(c) {
  await c.query(`
    CREATE TABLE IF NOT EXISTS tb_d12a2_trabalho (
      id               INT AUTO_INCREMENT PRIMARY KEY,
      st_tipo          VARCHAR(30) NOT NULL,
      ds_payload       VARCHAR(200) NOT NULL,
      st_status        VARCHAR(12) NOT NULL DEFAULT 'pendente',
      dt_criacao       DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
      dt_processamento DATETIME NULL,
      nr_tentativas    INT NOT NULL DEFAULT 0
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);

  // O indice e o que torna o `SELECT` do worker barato: ele filtra por
  // `st_status` e ordena por `id`, e sem indice o worker le a tabela inteira
  // a cada ciclo. O `IF NOT EXISTS` do MySQL nao existe para indice, entao o
  // erro de "ja existe" e esperado — e engolido de proposito, porque o exemplo
  // roda varias vezes seguidas.
  try {
    await c.query(
      'CREATE INDEX ix_d12a2_status ON tb_d12a2_trabalho (st_status, id)');
  } catch (erro) {
    if (erro.code !== 'ER_DUP_KEYNAME') throw erro;
  }
}

// ====================================================== enfileirar (o INSERT)
// Enfileirar e um `INSERT` e nada mais. E por isso que a fila sobrevive a
// restart: o trabalho esta gravado antes de qualquer worker existir.
async function enfileirar(c, tipo, payload) {
  const [r] = await c.execute(
    'INSERT INTO tb_d12a2_trabalho (st_tipo, ds_payload) VALUES (?, ?)',
    [tipo, JSON.stringify(payload)]);
  return r.insertId;
}

// ================================================ consumir (o SELECT + UPDATE)
// Pegar o trabalho mais antigo da fila e MARCA-lo como `processando`.
//
// A marcacao vem ANTES do trabalho de proposito: e ela que impede dois worker
// de pegarem a mesma linha. A forma simples e a que o exemplo usa — `SELECT`
// e `UPDATE` separados, sem `FOR UPDATE` e sem transacao aberta. Com uma
// unica conexao por worker, o `UPDATE ... WHERE st_status = 'pendente'` devolve
// `affectedRows = 0` para o worker que chegou atras, e ele segue para o proximo.
async function pegarTrabalho(c) {
  const [linhas] = await c.query(
    "SELECT id, st_tipo, ds_payload FROM tb_d12a2_trabalho "
    + "WHERE st_status = 'pendente' ORDER BY id LIMIT 1");

  if (linhas.length === 0) return null;

  const trabalho = linhas[0];
  const [marca] = await c.execute(
    "UPDATE tb_d12a2_trabalho SET st_status = 'processando', "
    + 'dt_processamento = NOW() '
    + "WHERE id = ? AND st_status = 'pendente'",
    [trabalho.id]);

  // `affectedRows = 0` significa que outro worker chegou antes: o trabalho nao
  // e mais deste. Devolver `null` e o comportamento certo — o ciclo dele segue.
  if (marca.affectedRows === 0) return null;

  return { ...trabalho, payload: JSON.parse(trabalho.ds_payload) };
}

// ====================================================== terminar (o UPDATE)
// O fim do ciclo: `feito` ou `erro`, e o `nr_tentativas` sobe 1. O contador e
// o que responde "quantas vezes isso ja tentou", e ele so muda AQUI — na
// marcacao de `processando` ele ja diria que o trabalho foi pego, nao tentado.
async function terminar(c, id, status) {
  await c.execute(
    'UPDATE tb_d12a2_trabalho SET st_status = ?, '
    + 'nr_tentativas = nr_tentativas + 1 WHERE id = ?',
    [status, id]);
}

// ===================================================== o trabalho de verdade
// Cada `st_tipo` faz uma coisa diferente. O `email-quebrado` e o tipo que
// sempre falha, e ele existe para mostrar o caminho do `erro` e da tentativa.
async function executar(trabalho) {
  switch (trabalho.st_tipo) {
    case 'email-confirmacao':
      await esperar(80);
      return 'e-mail de confirmacao enviado';
    case 'redimensionar-imagem':
      await esperar(120);
      return 'imagem redimensionada para 800x600';
    case 'email-quebrado':
      await esperar(60);
      // O erro traz `code` porque e o `erro.code` que o `catch` decide por
      // ele, e nao a `message`. A `message` explica; o `code` compara.
      throw Object.assign(
        new Error('servidor de e-mail recusou a mensagem'),
        { code: 'ER_ENVIO_RECUSADO' });
    default:
      throw Object.assign(new Error('tipo desconhecido: ' + trabalho.st_tipo),
        { code: 'ER_TIPO_DESCONHECIDO' });
  }
}

// ================================================================ o worker
// Um ciclo: pega um trabalho, executa, marca `feito` ou `erro`, devolve o
// event loop. Devolve `false` quando a fila esta vazia, e e isso que fecha o
// `while` do exemplo — em producao quem fecha o ciclo e o proprio worker.
async function cicloDoWorker(c, rotulo) {
  const trabalho = await pegarTrabalho(c);
  if (!trabalho) return false;

  try {
    const resultado = await executar(trabalho);
    await terminar(c, trabalho.id, 'feito');
    console.log('    [worker ' + rotulo + '] exec: ' + trabalho.st_tipo
      + '  ->  feito (' + resultado + ')');
  } catch (erro) {
    // `console.error` vai para o stderr, que NAO chega na pagina. O par e o que
    // o CONTRATO.md exige: o terminal mostra o erro, a pagina tambem.
    console.error('[worker ' + rotulo + '] exec: ' + trabalho.st_tipo
      + '  ->  erro: ' + erro.code + ' - ' + erro.message);
    console.log('    [worker ' + rotulo + '] exec: ' + trabalho.st_tipo
      + '  ->  erro: ' + erro.code + ' - ' + erro.message);
    // Marcar `erro` e o que tira o trabalho da frente do worker: sem isso a
    // fila o reprocessa para sempre, e cada tentativa gasta o mesmo tempo.
    await terminar(c, trabalho.id, 'erro');
  }
  return true;
}

// ================================================ o retrabalho (o worker morre)
// Um worker que morre no meio deixa o trabalho em `processando` PARA SEMPRE:
// ninguem vai pegar, porque o `SELECT` so olha `pendente`.
//
// O resgate e comparar a data: um `processando` com `dt_processamento` mais
// antiga que o limite pertence a um worker que nao existe mais, e volta para
// `pendente`. Sem esse resgate, um unico worker que caiu perde um trabalho
// para sempre e nada na fila avisa.
async function resgatarPresos(c, limiteMinutos) {
  const [r] = await c.execute(
    "UPDATE tb_d12a2_trabalho SET st_status = 'pendente', "
    + 'dt_processamento = NULL '
    + "WHERE st_status = 'processando' "
    + 'AND dt_processamento < DATE_SUB(NOW(), INTERVAL ? MINUTE)',
    [limiteMinutos]);
  return r.affectedRows;
}

// ============================================================== a demonstracao
async function main() {
  const c = await createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
    multipleStatements: true,
  });

  await criarFila(c);
  // `TRUNCATE` e nao `DELETE` por dois motivos: apaga tudo, e tambem zera o
  // `AUTO_INCREMENT`. Com `DELETE` os `id` continuariam de onde pararam, e a
  // saida embutida na pagina diria `id 6` numa execucao e `id 41` na outra.
  await c.query('TRUNCATE TABLE tb_d12a2_trabalho');

  const mostrar = async (onde) => {
    const [linhas] = await c.query(
      'SELECT id, st_tipo, st_status, nr_tentativas FROM tb_d12a2_trabalho '
      + 'ORDER BY id');
    console.log('  ' + onde + ': ' + linhas.length + ' linha(s) na fila');
    for (const l of linhas) {
      console.log('    id ' + String(l.id).padStart(2) + '  '
        + l.st_tipo.padEnd(21) + l.st_status.padEnd(13)
        + 'tentativas=' + l.nr_tentativas);
    }
  };

  const drenar = async (rotulo) => {
    while (await cicloDoWorker(c, rotulo)) { /* enquanto a fila tiver trabalho */ }
  };

  // ------------------------------------------------- 1. a requisicao enfileira
  console.log('--- 1. a requisicao enfileira e responde ---');
  console.log('tres INSERT e nada mais: e tudo que a requisicao faz antes de responder');
  const id1 = await enfileirar(c, 'email-confirmacao', { cliente: 'ana', pedido: 1 });
  const id2 = await enfileirar(c, 'redimensionar-imagem', { arquivo: 'capa.png' });
  const id3 = await enfileirar(c, 'email-confirmacao', { cliente: 'bruno', pedido: 2 });
  console.log('enfileirados os trabalhos ' + id1 + ', ' + id2 + ' e ' + id3);
  console.log('a requisicao nao espera nenhum deles terminar — so grava que eles existem');
  console.log('o `ds_payload` e JSON em texto: e por isso que o mesmo worker atende');
  console.log('e-mail, imagem e qualquer tipo novo sem mudar o schema');
  await mostrar('estado da fila');

  // ------------------------------------------------------ 2. o worker consome
  console.log('\n--- 2. o worker consome, um por ciclo ---');
  console.log('cada ciclo: SELECT pendente ORDER BY id LIMIT 1, marca, processa, marca');
  await drenar('A');
  console.log('a fila esvaziou: o proximo SELECT devolve 0 linhas e o worker dorme');
  console.log('o `ORDER BY id` e o que garante ordem: o mais antigo primeiro');
  await mostrar('estado da fila');

  // --------------------------------------------- 3. o trabalho que dá erro
  console.log('\n--- 3. o caminho do erro ---');
  console.log('enfileirando um trabalho cujo tipo sempre falha');
  const id4 = await enfileirar(c, 'email-quebrado', { cliente: 'clara', pedido: 3 });
  await drenar('B');
  const [comErro] = await c.query(
    'SELECT st_status, nr_tentativas FROM tb_d12a2_trabalho WHERE id = ?', [id4]);
  console.log('trabalho ' + id4 + ' terminou em ' + comErro[0].st_status
    + ', com nr_tentativas = ' + comErro[0].nr_tentativas);
  console.log('st_status = erro tira o trabalho da frente do worker: ele nao reprocessa sozinho');
  console.log('o `nr_tentativas` e quantas vezes o trabalho foi TENTADO, nao quantas');
  console.log('foi pego — a marcacao de `processando` nao soma, e o fim do ciclo soma');
  console.log('um trabalho em `erro` e para decisao humana; a fila nao decide sozinho');
  await mostrar('estado da fila');

  // ---------------------------------------------- 4. a marcacao trava o worker
  console.log('\n--- 4. dois worker, um trabalho: a marcacao e a trava ---');
  const id5 = await enfileirar(c, 'email-confirmacao', { cliente: 'eva', pedido: 9 });
  console.log('trabalho ' + id5 + ' enfileirado; agora dois worker chamam '
    + 'pegarTrabalho() ao mesmo tempo');
  const [pegaA, pegaB] = await Promise.all([
    pegarTrabalho(c), pegarTrabalho(c),
  ]);
  console.log('  worker A pegou: ' + (pegaA ? 'trabalho ' + pegaA.id : 'nada'));
  console.log('  worker B pegou: ' + (pegaB ? 'trabalho ' + pegaB.id : 'nada'));
  console.log('ninguem pegou o mesmo: o `WHERE st_status = "pendente"` do UPDATE so');
  console.log('marca quem ainda estava pendente, e o outro recebeu affectedRows = 0');
  console.log('foi esse detalhe que impediu o cliente de ser cobrado duas vezes');
  console.log('o trabalho ' + id4 + ' em `erro` NAO foi tocado por esse teste:');
  console.log('ele saiu da fila quando falhou, e a fila nao devolve trabalho por conta propria');
  if (pegaA) await terminar(c, pegaA.id, 'feito');

  // ------------------------------------------------ 5. o worker que morreu
  console.log('\n--- 5. o worker que morreu no meio ---');
  // O trabalho fica em `processando` com uma data de 2 minutos atras: e o que
  // sobra quando o processo e morto no meio da execucao — sem `catch`, sem
  // `finally`, sem nada que escreva o estado final.
  await c.execute(
    "INSERT INTO tb_d12a2_trabalho "
    + "(st_tipo, ds_payload, st_status, dt_processamento) "
    + "VALUES ('email-confirmacao', ?, 'processando', "
    + 'DATE_SUB(NOW(), INTERVAL 2 MINUTE))',
    [JSON.stringify({ cliente: 'dan', pedido: 4 })]);
  const [preso] = await c.query(
    "SELECT id FROM tb_d12a2_trabalho WHERE st_status = 'processando'");
  console.log('trabalho ' + preso[0].id + ' ficou `processando` ha 2 minutos: '
    + 'o worker morreu antes de terminar');
  console.log('qualquer worker novo ve a fila e nao pega: o SELECT so olha `pendente`');

  const [visiveis] = await c.query(
    "SELECT COUNT(*) AS n FROM tb_d12a2_trabalho WHERE st_status = 'pendente'");
  console.log('trabalho `pendente` visivel para o worker agora: ' + visiveis[0].n);
  console.log('\nchamando resgatarPresos(limite = 1 minuto)');
  const resgatados = await resgatarPresos(c, 1);
  console.log('trabalhos devolvidos para `pendente`: ' + resgatados);
  console.log('o resgate de producao roda com limite de 5 a 10 minutos, num ciclo');
  console.log('um `processando` de 2 horas e o sintoma classico de worker parado');
  console.log('este mesmo resgate roda logo apos o restart do servidor, e e o que');
  console.log('impede que o reinicio jogue fora trabalho que ja estava em andamento');
  await drenar('C');
  await mostrar('estado final');

  // ---------------------------------------------------------- 6. o ciclo todo
  console.log('\n--- 6. o ciclo inteiro, por status ---');
  const [finais] = await c.query(
    'SELECT st_status, COUNT(*) AS n, SUM(nr_tentativas) AS tentativas '
    + 'FROM tb_d12a2_trabalho GROUP BY st_status ORDER BY st_status');
  console.log('  status       trabalhos   tentativas');
  for (const f of finais) {
    console.log('  ' + f.st_status.padEnd(14) + String(f.n).padStart(5)
      + String(f.tentativas).padStart(12));
  }
  console.log('nenhum trabalho ficou `pendente` nem `processando`: quem nao terminou');
  console.log('com efeito esta em `erro`, esperando decisao humana — nada se perde');
  console.log('e o servidor pode reiniciar agora: a fila esta no MySQL, nao na memoria');

  await c.end();
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

--- 1. a requisicao enfileira e responde ---
tres INSERT e nada mais: e tudo que a requisicao faz antes de responder
enfileirados os trabalhos 1, 2 e 3
a requisicao nao espera nenhum deles terminar — so grava que eles existem
o `ds_payload` e JSON em texto: e por isso que o mesmo worker atende
e-mail, imagem e qualquer tipo novo sem mudar o schema
  estado da fila: 3 linha(s) na fila
    id  1  email-confirmacao    pendente     tentativas=0
    id  2  redimensionar-imagem pendente     tentativas=0
    id  3  email-confirmacao    pendente     tentativas=0

--- 2. o worker consome, um por ciclo ---
cada ciclo: SELECT pendente ORDER BY id LIMIT 1, marca, processa, marca
    [worker A] exec: email-confirmacao  ->  feito (e-mail de confirmacao enviado)
    [worker A] exec: redimensionar-imagem  ->  feito (imagem redimensionada para 800x600)
    [worker A] exec: email-confirmacao  ->  feito (e-mail de confirmacao enviado)
a fila esvaziou: o proximo SELECT devolve 0 linhas e o worker dorme
o `ORDER BY id` e o que garante ordem: o mais antigo primeiro
  estado da fila: 3 linha(s) na fila
    id  1  email-confirmacao    feito        tentativas=1
    id  2  redimensionar-imagem feito        tentativas=1
    id  3  email-confirmacao    feito        tentativas=1

--- 3. o caminho do erro ---
enfileirando um trabalho cujo tipo sempre falha
    [worker B] exec: email-quebrado  ->  erro: ER_ENVIO_RECUSADO - servidor de e-mail recusou a mensagem
trabalho 4 terminou em erro, com nr_tentativas = 1
st_status = erro tira o trabalho da frente do worker: ele nao reprocessa sozinho
o `nr_tentativas` e quantas vezes o trabalho foi TENTADO, nao quantas
foi pego — a marcacao de `processando` nao soma, e o fim do ciclo soma
um trabalho em `erro` e para decisao humana; a fila nao decide sozinho
  estado da fila: 4 linha(s) na fila
    id  1  email-confirmacao    feito        tentativas=1
    id  2  redimensionar-imagem feito        tentativas=1
    id  3  email-confirmacao    feito        tentativas=1
    id  4  email-quebrado       erro         tentativas=1

--- 4. dois worker, um trabalho: a marcacao e a trava ---
trabalho 5 enfileirado; agora dois worker chamam pegarTrabalho() ao mesmo tempo
  worker A pegou: trabalho 5
  worker B pegou: nada
ninguem pegou o mesmo: o `WHERE st_status = "pendente"` do UPDATE so
marca quem ainda estava pendente, e o outro recebeu affectedRows = 0
foi esse detalhe que impediu o cliente de ser cobrado duas vezes
o trabalho 4 em `erro` NAO foi tocado por esse teste:
ele saiu da fila quando falhou, e a fila nao devolve trabalho por conta propria

--- 5. o worker que morreu no meio ---
trabalho 6 ficou `processando` ha 2 minutos: o worker morreu antes de terminar
qualquer worker novo ve a fila e nao pega: o SELECT so olha `pendente`
trabalho `pendente` visivel para o worker agora: 0

chamando resgatarPresos(limite = 1 minuto)
trabalhos devolvidos para `pendente`: 1
o resgate de producao roda com limite de 5 a 10 minutos, num ciclo
um `processando` de 2 horas e o sintoma classico de worker parado
este mesmo resgate roda logo apos o restart do servidor, e e o que
impede que o reinicio jogue fora trabalho que ja estava em andamento
    [worker C] exec: email-confirmacao  ->  feito (e-mail de confirmacao enviado)
  estado final: 6 linha(s) na fila
    id  1  email-confirmacao    feito        tentativas=1
    id  2  redimensionar-imagem feito        tentativas=1
    id  3  email-confirmacao    feito        tentativas=1
    id  4  email-quebrado       erro         tentativas=1
    id  5  email-confirmacao    feito        tentativas=1
    id  6  email-confirmacao    feito        tentativas=1

--- 6. o ciclo inteiro, por status ---
  status       trabalhos   tentativas
  erro              1           1
  feito             5           5
nenhum trabalho ficou `pendente` nem `processando`: quem nao terminou
com efeito esta em `erro`, esperando decisao humana — nada se perde
e o servidor pode reiniciar agora: a fila esta no MySQL, nao na memoria