Dia 12 — Trabalho que demora
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.
| Elo | Onde o limite é escrito | O que acontece quando estoura |
|---|---|---|
| navegador | fetch(url, { signal: AbortSignal.timeout(3000) }) | TimeoutError, a tela fica esperando |
| proxy | nginx: proxy_read_timeout 60s | 504 Gateway Timeout |
| Node | server.requestTimeout, server.headersTimeout | fecha o socket sem responder |
| MySQL | max_execution_time por consulta | aborta 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 msno 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ção | Sincrono ou assíncrono | Por quê |
|---|---|---|
| gravar o pedido | síncrono | o cliente precisa saber que o pedido existe, e o id volta na resposta |
| cobrar o pagamento | síncrono | a resposta confirma a cobrança; devolver antes é dar o produto de graça |
| validar a senha | síncrono | a resposta é o sim ou o não; não há o que fazer depois |
| e-mail de confirmação | assíncrono | o cliente não lê o e-mail antes de voltar para a tela |
| gerar PDF do relatório | assíncrono | o arquivo vai ser acessado depois, e o painel avisa que está pronto |
| redimensionar imagem | assíncrono | o upload já devolveu; o tamanho final é detalhe |
| importar arquivo grande | assíncrono | leva 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 Acceptedexiste para o caminho assíncrono: ele diz "recebi, ainda não terminei". Devolver200e esconder o trabalho em umsetTimeouté 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.timeoutgrande demais só transfere o problema para o proxy, que devolve504— 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().
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
INSERTda fila é a última coisa que a requisição faz, e o que vem depois é trabalho de fundo que sobrevive ao restart. UmsetTimeoutda aula 1 não sobrevive — ele morre com o processo, e o cliente levou um200de 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 oORDER 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
processandoANTES de processar é a trava. Sem isso, dois workers chamam oSELECTno mesmo instante, os dois recebem a mesma linha, e o mesmo cliente é cobrado duas vezes. WHERE st_status = 'pendente'noUPDATEé o que fecha o caso dos dois workers. O segundo recebeaffectedRows = 0, sabe que o trabalho não é dele, e segue para o próximo. Não éSELECT ... FOR UPDATEnem transação: éUPDATEcondicional, 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 1 | Aula 2 | |
|---|---|---|
| quem guarda o trabalho | ninguém | a tabela tb_d12a2_trabalho |
| se o processo reinicia | o trabalho se perde | o trabalho continua na fila |
| se dois workers pegam o mesmo | não existe esse caso | a marcação resolve |
| se o worker morre | o trabalho se perde | o 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, semUPDATEcondicional, 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
setImmediateesetTimeoutaqui é de quando, não de onde.setTimeoutagenda para depois de um tempo;setImmediateroda 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 mesmoDELETEdo começo do exemplo: um zera oAUTO_INCREMENTe o outro não. É por isso que o exemplo usaTRUNCATEpara recomeçar do zero — assim a saída embutida dizid 1numa execução eid 1na 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