Trabalho que demora — Arduino e IoT — semana 7 do 3o trimestre
Semana 7 de 15· 3o trimestre · 05/09 a 10/12
Trabalho que demora
O pedido leva oito segundos e a resposta tem de sair agora.
Aula 1 — Fila: o trabalho sai da frente do usuario
Objetivos
- Explicar por que um pedido de oito segundos não pode ficar esperando na frente de quem fez o pedido, e o que a resposta imediata devolve no lugar.
- Escrever o enfileirar de uma tarefa demorada e o processar fila do lado do worker, com o mesmo identificador nos dois lados.
- Devolver o id do trabalho na resposta, escrever o status do trabalho, e consultar o andamento pelo mesmo identificador.
- Medir o tempo da resposta imediata e o tempo de cada trabalho, e mostrar que a fila é o que separa os dois números.
- Reproduzir a perda da fila no reinício com a fila em memória, e mostrar o que muda com a fila no banco.
Material
- 1 ESP32 DevKit V1 por dupla, com o cabo USB
- 1 LED da placa no
GPIO2, com o resistor de 330 ohm da bancada - 1 computador com o projeto do 2o trimestre aberto, com o servidor Node rodando
- Folha de papel por dupla, com o desenho do pedido, da resposta e da fila
- 1 computador com o monitor serial aberto a 115200
- Projetor, para o terminal com a fila crescendo e com a resposta saindo na hora
Conceitos
Tarefa demorada não espera na frente
Uma tarefa demorada é um trabalho que leva mais tempo do que o usuário aceita esperar. No projeto do curso são três: gerar o relatório do dia, recalcular o gráfico depois de uma importão grande, e reprocessar as leituras que ficaram na fila. Todos os três levam segundos, e todos os três são disparados por um clique.
O problema do pedido que espera é que ele não termina sozinho: ele ocupa a conexão, ocupa um espaço do servidor enquanto trabalha, e a tela do usuário fica esperando. Com um relatório na frente, a fila de conexões do servidor fica ocupada com um trabalho que não responde nada, e os pedidos da placa — que são rápidos — começam a esperar atrás dele.
A solução é a resposta imediata: o servidor recebe o pedido, devolve um id do trabalho e encerra a conexão em milissegundos. É o que se chama de aceitar e processar depois: o aceite vem no mesmo instante, o processamento não. O trabalho vai para a fila e é executado depois, fora da frente. Quem pediu ficou com um identificador e pode consultar o andamento quando quiser, em vez de ficar olhando a tela.
O que muda na experiência do usuário é grande e o professor mede: a resposta saiu em milissegundos em vez de oito segundos. O que muda na arquitetura é maior ainda, e é a parte que interessa aqui: o trabalho pode ser retomado, pode ser tentado de novo se falhar, e pode ser distribuído para outro processo sem que o usuário perceba nada disso.
O que a resposta imediata não faz é resolver o trabalho. Ela não faz o relatório; ela registra a promessa. E é por isso que o status do trabalho é parte do contrato e não um detalhe: quem recebeu o id precisa conseguir descobrir o que aconteceu com ele, inclusive o caso em que nunca mais vai acontecer.
O desenho da lousa tem três tempos, e o que importa é que o segundo é opcional do ponto de vista do servidor:
| Tempo | O que acontece | Quem espera |
|---|---|---|
| resposta imediata | o servidor devolve o id do trabalho | quem pediu, alguns milissegundos |
| consulta de andamento | o status responde pendente ou pronto | quem pede, quando quiser |
| processamento em segundo plano | o worker executa a tarefa demorada | ninguém |
Enfileirar, processar e o contrato do id do trabalho
Enfileirar é colocar a tarefa na fila; processar fila é tirar tarefas da fila e executá-las. Quem enfileira é o servidor que atendeu o pedido; quem processa é o worker, que é um processo separado do servidor web. Consumidor é o mesmo papel visto do outro lado da fila: o worker é o consumidor da fila.
A separação entre servidor web e worker é o que dá o nome de segundo plano, ou *background job*, que é o termo em inglês que aparece em todo código de fila. Um trabalho em segundo plano é um trabalho que roda sem depender de alguém estar esperando, e a consequência prática é que ele sobrevive ao fim da conexão: o usuário pode fechar o navegador que o relatório continua sendo gerado.
O id do trabalho é o contrato entre os dois lados, e ele precisa estar em três lugares:
- Na resposta ao usuário, que recebeu o id e vai consultá-lo.
- No registro do trabalho, onde o servidor guarda estado, resultado e erro.
- Na mensagem da fila, para que o worker saiba qual registro atualizar quando terminar.
O id tem que ser o mesmo nos três, e essa exigência é o que permite a segunda consulta: o usuário manda o id, o servidor acha o registro, e o worker tinha atualizado o mesmo registro. Se qualquer um dos três usasse um id diferente, a consulta mostraria um trabalho que não termina nunca.
O status do trabalho tem três campos que a turma precisa escrever, e o professor não aceita menos que três:
- estado: pendente, em andamento ou concluído, com a opção de falhou.
- progresso: quanto já foi feito, quando a tarefa tem etapas.
- erro: a mensagem do último erro, quando houve.
Sem o campo de erro, um trabalho que falhou é indistinguível de um trabalho que nunca foi processado, e a equipe passa a reiniciar o servidor achando que a fila travou. O campo de erro é o que fecha o status do trabalho.
A consultar o andamento pelo id é o que transforma a resposta imediata em algo aceitável para o usuário. O padrão do projeto é uma rota que recebe o id e devolve o status, e a turma escreve a resposta do item 7 da atividade com os três campos preenchidos. O que a consulta não pode fazer é recalcular o trabalho: ela lê o registro, e quem escreve no registro é o worker.
Medir os dois tempos: resposta e trabalho
O ponto que o professor insiste é que são dois números, e a confusão entre eles é o que faz a equipe acreditar que a fila deixou tudo rápido.
O tempo de resposta é o que o usuário espera: o tempo entre o clique e a resposta com o id. Ele é medido no relógio do servidor, entre a entrada da rota e a saída da resposta. Na bancada ele fica na casa das dezenas de milissegundos, e é esse o número que o professor mede com o sketch.
O tempo do trabalho é o que o trabalho demora depois: é o relógio do worker, do começo ao fim da tarefa. Ele não tem limite de resposta, porque ninguém está esperando; tem limite de recurso, porque ocupa uma máquina.
A fila é o que separa os dois números, e a forma de ver isso é o que o item 2 da atividade mede: o tempo de resposta é da ordem de milissegundos, o tempo de trabalho é da ordem de segundos. Se os dois fossem iguais, não haveria fila.
O limite de tempo de resposta é a regra que a equipe escreve depois da medição, e ela é sempre mais alta que o número medido e sempre mais baixa que o tempo do trabalho. Um limite de tempo de resposta acima do tempo de trabalho é uma regra que nunca dispara; abaixo dos milissegundos medidos, ela dispara em toda requisição. O professor pede o número medido e o limite escrito, lado a lado, e pergunta o que acontece em cada um dos dois erros de escrita.
O que a fila não resolve, e que é a armadilha do dia: ela não resolve trabalho que demora porque é lento, e não dá controle sobre a ordem em que os trabalhos são executados. Uma fila com dez tarefas demoradas e uma máquina com um worker é uma fila que demora dez vezes o tempo de uma tarefa. Por isso a medição do tempo de trabalho importa tanto quanto a da resposta: é ela que diz quantos workers a fila precisa.
A fila em memória e a fila no banco
Uma fila de mensagens, a queue em inglês, é o armazenamento intermediário entre quem produz e quem consome. Uma fila em memória é uma estrutura do processo: um vetor, uma lista, uma variável. A tarefa é enfileirada escrevendo nela, e é consumida retirando dela. É a implementação mais simples que existe, e é a que todo mundo escreve primeiro.
O problema dela tem nome, e o nome é perder fila no reinício. A fila está na memória do processo; quando o processo morre — reinício, deploy, queda, *crash* — a memória vai junto e a fila vai com ela. Os trabalho que estavam na fila desaparecem, e o usuário que recebeu o id fica com um trabalho que nunca existiu.
O professor demonstra isso na aula, e é a demonstração mais curta e mais forte do dia: enfileira três tarefas, reinicia o servidor, e os três ids continuam no banco de registro com estado pendente para sempre. O registro diz que o trabalho existe; a fila diz que ele não vai acontecer. O status do trabalho responde "pendente" para sempre, e a equipe não tem como saber que ele se perdeu.
Uma fila no banco guarda a tarefa em uma tabela, e é isso que muda o resultado do reinício. A fila é uma tabela com número, identificador do trabalho, instante, estado e tentativas; o worker lê dela e escreve nela. Quando o processo morre, a tabela continua lá, e o worker que subir de novo encontra as tarefas pendentes e as processa. O trabalho sobrevive ao reinício, e o status do trabalho volta a se mover.
O quadro que o professor escreve na lousa resume a troca, e a turma copia:
| Fila em memória | Fila no banco | |
|---|---|---|
| montar | dez linhas | uma tabela e transação |
| velocidade | mais rápida | uma transação a mais |
| reinício do processo | perde a fila | a fila continua |
| envio duplicado | impossível de ver | visível pela linha repetida |
| consultas de andamento | precisa de memória | é a mesma tabela |
A diferença de velocidade é pequena e o professor dá o número: a transação extra é da ordem de milissegundos, e ela é o preço de não perder trabalho. A equipe que escolhe a fila em memória por causa do milissegundo economizado está trocando um milissegundo por dados perdidos.
O que a fila no banco também resolve, e que a aula 2 usa: como a tarefa está gravada antes de ser executada, dá para contar quantas vezes ela foi tentada. É esse contador que faz o retry com espera, que é o tema da aula 2. E é ele que permite saber, na consulta do andamento, se o trabalho está na primeira tentativa ou na terceira.
O que nenhuma das duas filas resolve sozinho é o trabalho duplicado: fila em memória perde ao reiniciar, fila no banco duplica quando o worker processa e morre antes de marcar o trabalho como concluído. A correção é a mesma chave de idempotência da aula anterior aplicada ao trabalho, e ela é o que fecha o ciclo entre as duas aulas.
Atividade
Montagem:
LEDda placa noGPIO2, com o resistor de 330 ohm e o jumper para oGND.- Cabo USB conectado, monitor serial em 115200.
- Servidor Node do 2o trimestre rodando, com a tabela de registro de trabalhos vazia.
- Folha de papel com os três tempos do desenho da lousa.
- Desenhe na folha os três tempos: resposta imediata, consulta de andamento e processamento em segundo plano. Escreva o relógio de cada um e quanto tempo cada um leva na sua bancada. Qual dos três é o tempo de resposta?
- Grave o sketch da resolução e rode por três minutos. Copie no caderno o tempo de resposta medido, o
iddo trabalho devolvido e o tempo de trabalho doworker. Some nada: escreva os dois números na mesma linha e diga quantas vezes o primeiro é menor que o segundo. - Escreva a resposta do pedido em uma linha, do jeito que ela sai no
HTTP. Quais campos tem, e onde está o id do trabalho? Escreva a resposta dostatusde um trabalho em andamento e de um trabalho concluído, com os três campos preenchidos. - Enfileire três tarefas demoradas e consulte o andamento de cada uma pelos três ids. Copie aqui os três estados. O
idque veio na resposta é o mesmo que você usou na consulta? - Reinicie o servidor com três tarefas na fila e consulte os três ids de novo. O que os
statusresponderam? Quantos trabalho ainda vão acontecer? Escreva em uma frase por que a fila em memória perdeu esses três. - Escreva a tabela da fila no banco no seu projeto, com os campos: número, id do trabalho, instante, estado, tentativas. Escreva a consulta que o worker faz para pegar a próxima tarefa pendente. Ela trava a linha quando pega?
- Meça o tempo de resposta com a carga de dez tarefas na fila e escreva o número. Ele mudou em relação ao item 2? Meça também o tempo de trabalho de uma tarefa nessa condição. Qual dos dois números explica melhor por que a fila precisa de mais de um worker?
- Escreva no seu projeto o limite de tempo de resposta e o limite de tempo de trabalho, com os dois números ao lado dos medidos. O que acontece se o limite de resposta for menor que o número medido? E se for maior que o tempo de trabalho?
Nota: 12 pontos. Critério de fim: os dois tempos medidos do item 2, as duas respostas do status do item 3 e os três estados do item 4, e o resultado dos três ids depois do reinício no item 5.
Resolucao
O sketch da aula está em codigo/t3/dia07/aula1.ino.
#include <Arduino.h> #include <esp_timer.h> // =========================================================================== // dia 7, aula 1: tarefa demorada em segundo plano. // // A placa NAO e o servidor e NAO tem fila. O sketch reproduz os TRES TEMPOS // da aula no mesmo lugar, para que os dois relogios fiquem visiveis juntos: // // 1. resposta imediata - o que o usuario espera: milissegundos // 2. consulta de andamento - o status pelos tres campos // 3. processamento em segundo plano - o worker, sem ninguem esperando // // E mostra a perda da fila em memoria no reinicio, que e o numero que faz a // fila ir para o banco. // =========================================================================== const int PIN_LED = 2; const unsigned long INTERVALO_MS = 4000; // O tempo de trabalho do relatorio: a tarefa demorada da aula. const unsigned long TEMPO_DO_TRABALHO_MS = 8000; // A capacidade da fila em memoria: e o que o reinicio apaga. const int CAPACIDADE_FILA = 8; // --------------------------------------------------------------------------- // O REGISTRO DO TRABALHO. E a fila no banco, em memoria, e o que sobrevive a // um reinicio do worker. O id e o mesmo nos tres lugares: resposta, registro // e mensagem da fila. // --------------------------------------------------------------------------- struct Trabalho { unsigned long id; const char* relatorio; char estado[20]; // pendente | em_andamento | concluido | falhou int progresso; // 0 a 100 const char* erro; // vazio quando nao houve }; const int MAX_TRABALHOS = 16; Trabalho REGISTRO[MAX_TRABALHOS]; int totalTrabalhos = 0; // A fila em memoria do worker. Nao sobrevive ao reinicio: e um vetor, e o // vetor vai com o processo. unsigned long filaEmMemoria[CAPACIDADE_FILA]; int itensNaFila = 0; unsigned long proximoId = 1000; // --------------------------------------------------------------------------- // TEMPO 1: A RESPOSTA IMEDIATA. O que o usuario espera. Medido com o relogio // de microsegundo, que e o do dia 11: milisegundo nao separa resposta de fila. // --------------------------------------------------------------------------- void enfileirar(const char* relatorio) { int64_t inicio = esp_timer_get_time(); proximoId++; REGISTRO[totalTrabalhos].id = proximoId; REGISTRO[totalTrabalhos].relatorio = relatorio; strcpy(REGISTRO[totalTrabalhos].estado, "pendente"); REGISTRO[totalTrabalhos].progresso = 0; REGISTRO[totalTrabalhos].erro = ""; if (itensNaFila < CAPACIDADE_FILA) { filaEmMemoria[itensNaFila++] = proximoId; // a fila em memoria } int64_t fim = esp_timer_get_time(); Serial.printf(" resposta imediata: {\"id\": %lu, \"estado\": \"pendente\"} " "em %.2f ms\n", proximoId, (float)(fim - inicio) / 1000.0f); totalTrabalhos++; } // --------------------------------------------------------------------------- // TEMPO 2: A CONSULTA DE ANDAMENTO. Le o registro e devolve os tres campos. // Nao recalcula nada: quem escreve no registro e o worker. // --------------------------------------------------------------------------- void consultarAndamento(unsigned long id) { for (int i = 0; i < totalTrabalhos; i++) { if (REGISTRO[i].id != id) continue; Serial.printf(" status %lu: %s | progresso %d%% | erro: %s\n", id, REGISTRO[i].estado, REGISTRO[i].progresso, REGISTRO[i].erro[0] ? REGISTRO[i].erro : "(nenhum)"); return; } Serial.printf(" status %lu: trabalho desconhecido\n", id); } // --------------------------------------------------------------------------- // TEMPO 3: O WORKER. Processa fila em segundo plano, atualiza o registro e // so entao marca como concluido. A ordem importa: o registro e atualizado // depois do trabalho, nunca antes. // --------------------------------------------------------------------------- void workerPasso() { if (itensNaFila == 0) { Serial.println(" worker: fila vazia, nada a fazer"); return; } unsigned long id = filaEmMemoria[0]; for (int i = 1; i < itensNaFila; i++) filaEmMemoria[i - 1] = filaEmMemoria[i]; itensNaFila--; for (int i = 0; i < totalTrabalhos; i++) { if (REGISTRO[i].id != id) continue; strcpy(REGISTRO[i].estado, "em_andamento"); Serial.printf(" worker: pegou o trabalho %lu, vai levar %lu s\n", id, TEMPO_DO_TRABALHO_MS / 1000); // O trabalho pesado acontece aqui, com o sleep que representa os 8 s. delay(TEMPO_DO_TRABALHO_MS / 4); REGISTRO[i].progresso = 50; Serial.printf(" status %lu: %s | progresso %d%% | erro: (nenhum)\n", id, REGISTRO[i].estado, REGISTRO[i].progresso); delay(TEMPO_DO_TRABALHO_MS / 4); REGISTRO[i].progresso = 100; strcpy(REGISTRO[i].estado, "concluido"); Serial.printf(" status %lu: %s | progresso %d%% | erro: (nenhum)\n", id, REGISTRO[i].estado, REGISTRO[i].progresso); return; } } // --------------------------------------------------------------------------- // A PERDA NO REINICIO. Zera o processo: a fila em memoria vai junto, e o // registro fica. E o numero que separa a fila em memoria da fila no banco. // --------------------------------------------------------------------------- void reiniciarProcesso() { int perdidos = itensNaFila; Serial.println("--- reiniciando o processo do worker ---"); Serial.printf(" fila em memória: %d trabalho(s) que iam rodar\n", perdidos); itensNaFila = 0; Serial.println(" registro: continua, os ids continuam lá"); Serial.println(" resultado: os status ficam em 'pendente' para sempre,"); Serial.println(" e o usuário que recebeu o id nunca recebe o relatório."); Serial.println(" Com a fila no banco, os mesmos três trabalhos sobrevivem."); Serial.println(); } void setup() { pinMode(PIN_LED, OUTPUT); Serial.begin(115200); delay(200); Serial.println(); Serial.println("=== dia 7, aula 1: o trabalho sai da frente do usuário ==="); Serial.println("Resposta em milissegundos. Trabalho em segundos. Fila no meio."); Serial.println(); unsigned long t0 = (unsigned long)esp_timer_get_time(); enfileirar("relatorio do dia"); unsigned long t1 = (unsigned long)esp_timer_get_time(); Serial.print(" tempo de resposta medido: "); Serial.print((t1 - t0) / 1000.0f); Serial.println(" ms"); Serial.print(" tempo do trabalho: "); Serial.print(TEMPO_DO_TRABALHO_MS / 1000); Serial.println(" s"); Serial.print(" o primeiro é "); Serial.print(TEMPO_DO_TRABALHO_MS / 1000); Serial.println(" vezes menor que o segundo: é a fila que separa os dois."); Serial.println(); enfileirar("recalcular grafico"); enfileirar("reprocessar leituras"); Serial.println(" três ids entregues. O trabalho do usuário não espera nenhum deles."); Serial.println(); } void loop() { digitalWrite(PIN_LED, HIGH); Serial.println("--- os três tempos ---"); Serial.print(" 1. "); Serial.println("resposta imediata: o que o usuário espera"); Serial.print(" 2. "); Serial.println("consulta de andamento: o status pelos três campos"); Serial.print(" 3. "); Serial.println("processamento em segundo plano: o worker, sem ninguém esperando"); Serial.println(); consultarAndamento(REGISTRO[0].id); workerPasso(); reiniciarProcesso(); Serial.println(" Onde a fila em memória ganha e onde perde:"); Serial.println(" ganha: dez linhas de código, e a mais rápida das duas"); Serial.println(" perde: tudo, quando o processo reinicia"); Serial.println(" a fila no banco paga uma transação e não perde trabalho"); Serial.println(); digitalWrite(PIN_LED, LOW); delay(INTERVALO_MS); }
Por que assim e não de outro jeito. O sketch roda os três tempos no mesmo lugar porque a aula precisa dos dois relógios juntos. Se a resposta imediata fosse medida numa tela e o tempo de trabalho em outra, a turma compararia dois números de contextos diferentes e a conclusão seria "¿Por que oito segundos?". Imprimidos na mesma tela, na mesma volta, a razão fica óbvia: a resposta saiu em décimos de milissegundo e o trabalho leva oito segundos, e a fila é exatamente a distância entre os dois.
A struct Trabalho tem cinco campos, e o quinto — erro — é o que a turma acha dispensável. Os quatro primeiros respondem "o que está acontecendo"; o quinto responde "o que aconteceu de errado". Sem ele, um trabalho que falhou é indistinguível de um trabalho que ninguém pegou, e a equipe reinicia o servidor achando que a fila travou. O campo de erro é o que fecha o status do trabalho, e o sketch o imprime sempre, com (nenhum) quando está vazio — porque um campo ausente no registro é um campo que ninguém sabe ler.
O id é o mesmo em três lugares, e o professor aponta isso no código: ele nasce em enfileirar, entra no registro, e entra na fila no mesmo instante. A exigência de que o id seja o mesmo nos três é o que permite a consulta do item 4 da atividade funcionar. Se a fila usasse um número de posição e o registro usasse outro, o usuário consultaria um trabalho que nunca termina.
A REGISTRO e a filaEmMemoria são duas estruturas separadas de propósito, e a separação é o argumento inteiro da aula. O registro é o que a consulta de andamento lê, e ele sobrevive ao reinício. A fila é o que o worker lê, e ela morre com o processo. O reiniciarProcesso zera só a fila, e o efeito é exatamente o que a equipe vai ver em produção: os status ficam em pendente para sempre, sem nunca terem falhado.
O TEMPO_DO_TRABALHO_MS com oito milissegundos é o número do enunciado do dia, e ele é uma constante e não um sleep escrito à mão no meio da função. Com a constante, o item 8 da atividade — mudar o tempo de trabalho e ver o que acontece com o tempo de resposta — vira edição de uma linha, e a turma descobre que o tempo de resposta não muda. Essa é a propriedade da fila, demonstrada com número.
A divisão do trabalho em dois delay com o progresso passando a 50 no meio existe para mostrar o campo de progresso em uso. Uma tarefa que só vai de 0 a 100 não tem como demonstrar o campo, e o campo de progresso é justamente o que permite a um usuário de relatório de oito segundos saber que não travou. O professor pede que a turma escreva o status do meio do trabalho e compare com o do fim.
O LED acende durante a resposta, o processamento e a demonstração do reinício, e apaga no intervalo. O intervalo desta aula é de quatro segundos, e não de cinco, porque o trabalho simulado já leva dois segundos dos dois delay e a tela precisa ficar parada o suficiente para a turma ler o bloco.
Criterios de correcao
| Critério | Pontos |
|---|---|
| Os três tempos desenhados, com o relógio e a medição de cada um | 2 pontos |
| O tempo de resposta e o tempo de trabalho medidos, com a comparação escrita | 2 pontos |
| A resposta do pedido e as duas respostas do status com os três campos | 2 pontos |
| Os três ids enfileirados e consultados, com os três estados | 2 pontos |
| O resultado dos três ids depois do reinício, e a explicação da perda | 2 pontos |
| A tabela da fila no banco escrita, com a consulta do worker | 1 ponto |
| Os dois limites escritos, com o efeito dos dois erros de escrita | 1 ponto |
Erros comuns
| Erro | Como aparece | Correção |
|---|---|---|
| Fazer o relatório na frente | O clique trava a tela por oito segundos | "Resposta imediata: devolva o id do trabalho e processe em segundo plano. Quem pediu consulta o andamento quando quiser." |
| Devolver só "ok" sem id | O usuário não tem como perguntar o que aconteceu | "A resposta é o contrato: ela devolve o id do trabalho. Sem id não há como consultar o andamento." |
| Status sem campo de erro | Trabalho falhado parece trabalho nunca processado | "Os três campos são estado, progresso e erro. Sem o erro, ninguém sabe se a fila travou ou se o trabalho quebrou." |
| Recalcular na consulta | O status executa o relatório para responder | "A consulta lê o registro. Quem escreve no registro é o worker; se a consulta calcula, ela virou o trabalho." |
| Fila em memória em produção | O servidor reinicia e os relatórios param de acontecer | "Fila em memória é o vetor do processo, e o processo morre com ela. A fila no banco custa uma transação e não perde trabalho." |
| Confundir os dois tempos | "A fila deixou tudo rápido" e o relatório demora oito segundos | "São dois relógios: resposta em milissegundos, trabalho em segundos. A fila separa os dois; ela não encurta o trabalho." |
| Limite de resposta acima do trabalho | O alerta nunca dispara, porque nunca é atingido | "O limite fica acima do número medido e abaixo do tempo de trabalho. Os dois erros de escrita são alerta que nunca dispara e alerta que dispara sempre." |
| Uma máquina com um worker e dez tarefas | A fila cresce e o tempo de trabalho multiplica por dez | "Meça o tempo de trabalho sob carga. É ele que diz quantos workers a fila precisa — a resposta continua rápida nos dois casos." |
Desafio extra
Monte a fila no banco no seu projeto, com a tabela, a consulta que o worker usa para pegar a próxima tarefa e a transação que trava a linha. Depois rode o teste completo: enfileire dez tarefas demoradas, reinicie o servidor duas vezes no meio, e ao final consulte o andamento dos dez ids e escreva quantos chegaram a concluído. Repita o mesmo teste com a fila em memória e compare os dois números. Escreva na frente os dois resultados e diga quantos trabalhos se perderam em cada caso. Em seguida, diminua o tempo de trabalho para dois segundos, mantenha a carga de dez tarefas, e meça de novo o tempo de resposta e o tempo de trabalho. A pergunta que o professor espera de resposta: quantos workers o seu projeto precisaria para que o tempo de trabalho com dez tarefas na fila ficasse igual ao tempo de uma tarefa só, e o que muda no painel de operação quando há mais de um worker consumindo a mesma fila?
>A resolucao, compilada
// Aula 1 do dia 7 do 3o trimestre de ESP32 e IoT. // Gerado a partir da secao ## Resolucao de conteudo/t3/dia07/aula1.md. // Se mudar o codigo, mude no .md e regere: o .ino e copia do .md. #include <Arduino.h> #include <esp_timer.h> // =========================================================================== // dia 7, aula 1: tarefa demorada em segundo plano. // // A placa NAO e o servidor e NAO tem fila. O sketch reproduz os TRES TEMPOS // da aula no mesmo lugar, para que os dois relogios fiquem visiveis juntos: // // 1. resposta imediata - o que o usuario espera: milissegundos // 2. consulta de andamento - o status pelos tres campos // 3. processamento em segundo plano - o worker, sem ninguem esperando // // E mostra a perda da fila em memoria no reinicio, que e o numero que faz a // fila ir para o banco. // =========================================================================== const int PIN_LED = 2; const unsigned long INTERVALO_MS = 4000; // O tempo de trabalho do relatorio: a tarefa demorada da aula. const unsigned long TEMPO_DO_TRABALHO_MS = 8000; // A capacidade da fila em memoria: e o que o reinicio apaga. const int CAPACIDADE_FILA = 8; // --------------------------------------------------------------------------- // O REGISTRO DO TRABALHO. E a fila no banco, em memoria, e o que sobrevive a // um reinicio do worker. O id e o mesmo nos tres lugares: resposta, registro // e mensagem da fila. // --------------------------------------------------------------------------- struct Trabalho { unsigned long id; const char* relatorio; char estado[20]; // pendente | em_andamento | concluido | falhou int progresso; // 0 a 100 const char* erro; // vazio quando nao houve }; const int MAX_TRABALHOS = 16; Trabalho REGISTRO[MAX_TRABALHOS]; int totalTrabalhos = 0; // A fila em memoria do worker. Nao sobrevive ao reinicio: e um vetor, e o // vetor vai com o processo. unsigned long filaEmMemoria[CAPACIDADE_FILA]; int itensNaFila = 0; unsigned long proximoId = 1000; // --------------------------------------------------------------------------- // TEMPO 1: A RESPOSTA IMEDIATA. O que o usuario espera. Medido com o relogio // de microsegundo, que e o do dia 11: milisegundo nao separa resposta de fila. // --------------------------------------------------------------------------- void enfileirar(const char* relatorio) { int64_t inicio = esp_timer_get_time(); proximoId++; REGISTRO[totalTrabalhos].id = proximoId; REGISTRO[totalTrabalhos].relatorio = relatorio; strcpy(REGISTRO[totalTrabalhos].estado, "pendente"); REGISTRO[totalTrabalhos].progresso = 0; REGISTRO[totalTrabalhos].erro = ""; if (itensNaFila < CAPACIDADE_FILA) { filaEmMemoria[itensNaFila++] = proximoId; // a fila em memoria } int64_t fim = esp_timer_get_time(); Serial.printf(" resposta imediata: {\"id\": %lu, \"estado\": \"pendente\"} " "em %.2f ms\n", proximoId, (float)(fim - inicio) / 1000.0f); totalTrabalhos++; } // --------------------------------------------------------------------------- // TEMPO 2: A CONSULTA DE ANDAMENTO. Le o registro e devolve os tres campos. // Nao recalcula nada: quem escreve no registro e o worker. // --------------------------------------------------------------------------- void consultarAndamento(unsigned long id) { for (int i = 0; i < totalTrabalhos; i++) { if (REGISTRO[i].id != id) continue; Serial.printf(" status %lu: %s | progresso %d%% | erro: %s\n", id, REGISTRO[i].estado, REGISTRO[i].progresso, REGISTRO[i].erro[0] ? REGISTRO[i].erro : "(nenhum)"); return; } Serial.printf(" status %lu: trabalho desconhecido\n", id); } // --------------------------------------------------------------------------- // TEMPO 3: O WORKER. Processa fila em segundo plano, atualiza o registro e // so entao marca como concluido. A ordem importa: o registro e atualizado // depois do trabalho, nunca antes. // --------------------------------------------------------------------------- void workerPasso() { if (itensNaFila == 0) { Serial.println(" worker: fila vazia, nada a fazer"); return; } unsigned long id = filaEmMemoria[0]; for (int i = 1; i < itensNaFila; i++) filaEmMemoria[i - 1] = filaEmMemoria[i]; itensNaFila--; for (int i = 0; i < totalTrabalhos; i++) { if (REGISTRO[i].id != id) continue; strcpy(REGISTRO[i].estado, "em_andamento"); Serial.printf(" worker: pegou o trabalho %lu, vai levar %lu s\n", id, TEMPO_DO_TRABALHO_MS / 1000); // O trabalho pesado acontece aqui, com o sleep que representa os 8 s. delay(TEMPO_DO_TRABALHO_MS / 4); REGISTRO[i].progresso = 50; Serial.printf(" status %lu: %s | progresso %d%% | erro: (nenhum)\n", id, REGISTRO[i].estado, REGISTRO[i].progresso); delay(TEMPO_DO_TRABALHO_MS / 4); REGISTRO[i].progresso = 100; strcpy(REGISTRO[i].estado, "concluido"); Serial.printf(" status %lu: %s | progresso %d%% | erro: (nenhum)\n", id, REGISTRO[i].estado, REGISTRO[i].progresso); return; } } // --------------------------------------------------------------------------- // A PERDA NO REINICIO. Zera o processo: a fila em memoria vai junto, e o // registro fica. E o numero que separa a fila em memoria da fila no banco. // --------------------------------------------------------------------------- void reiniciarProcesso() { int perdidos = itensNaFila; Serial.println("--- reiniciando o processo do worker ---"); Serial.printf(" fila em memória: %d trabalho(s) que iam rodar\n", perdidos); itensNaFila = 0; Serial.println(" registro: continua, os ids continuam lá"); Serial.println(" resultado: os status ficam em 'pendente' para sempre,"); Serial.println(" e o usuário que recebeu o id nunca recebe o relatório."); Serial.println(" Com a fila no banco, os mesmos três trabalhos sobrevivem."); Serial.println(); } void setup() { pinMode(PIN_LED, OUTPUT); Serial.begin(115200); delay(200); Serial.println(); Serial.println("=== dia 7, aula 1: o trabalho sai da frente do usuário ==="); Serial.println("Resposta em milissegundos. Trabalho em segundos. Fila no meio."); Serial.println(); unsigned long t0 = (unsigned long)esp_timer_get_time(); enfileirar("relatorio do dia"); unsigned long t1 = (unsigned long)esp_timer_get_time(); Serial.print(" tempo de resposta medido: "); Serial.print((t1 - t0) / 1000.0f); Serial.println(" ms"); Serial.print(" tempo do trabalho: "); Serial.print(TEMPO_DO_TRABALHO_MS / 1000); Serial.println(" s"); Serial.print(" o primeiro é "); Serial.print(TEMPO_DO_TRABALHO_MS / 1000); Serial.println(" vezes menor que o segundo: é a fila que separa os dois."); Serial.println(); enfileirar("recalcular grafico"); enfileirar("reprocessar leituras"); Serial.println(" três ids entregues. O trabalho do usuário não espera nenhum deles."); Serial.println(); } void loop() { digitalWrite(PIN_LED, HIGH); Serial.println("--- os três tempos ---"); Serial.print(" 1. "); Serial.println("resposta imediata: o que o usuário espera"); Serial.print(" 2. "); Serial.println("consulta de andamento: o status pelos três campos"); Serial.print(" 3. "); Serial.println("processamento em segundo plano: o worker, sem ninguém esperando"); Serial.println(); consultarAndamento(REGISTRO[0].id); workerPasso(); reiniciarProcesso(); Serial.println(" Onde a fila em memória ganha e onde perde:"); Serial.println(" ganha: dez linhas de código, e a mais rápida das duas"); Serial.println(" perde: tudo, quando o processo reinicia"); Serial.println(" a fila no banco paga uma transação e não perde trabalho"); Serial.println(); digitalWrite(PIN_LED, LOW); delay(INTERVALO_MS); }
Sem saída de compilação gravada. Rode python3 validar.py -t 3 dia07 aula1.
Aula 2 — Retry com espera e o trabalho que roda duas vezes
Objetivos
- Escrever o retry com espera entre tentativas, com backoff exponencial e jitter, e explicar por que a espera existe.
- Medir o número de tentativas e escrever a regra de dar up depois de N tentativas, com o que o sistema faz com o trabalho abandonado.
- Reproduzir o trabalho duplicado e o efeito duplicado, e dizer por que a fila repete mesmo depois de processar.
- Aplicar a chave de idempotência no trabalho, e mostrar que executar duas vezes não vira não duplicar falha.
- Diagnosticar o envio duplicado da placa e escrever a correção que impede que o mesmo dado vire duas linhas.
Material
- 1 ESP32 DevKit V1 por dupla, com o cabo USB
- 1 LED da placa no
GPIO2, com o resistor de 330 ohm da bancada - 1 computador com o projeto do 2o trimestre aberto, com o servidor Node rodando
- Folha de papel por dupla, com a tabela de tentativas
- 1 computador com o monitor serial aberto a 115200
- Projetor, para o terminal com a sequência de tentativas e as esperas
Conceitos
Tentar de novo, e por que a espera não é vaidade
O retry é tentar de novo depois de uma falha: tente de novo, e espere antes. O termo em inglês aparece em todo código de fila, e o comportamento ingênuo — repetir até dar certo — é o que a seção 3 desmonta. Tentar de novo é a operação; o que vem com ela é a decisão de quando, e essa decisão é quase toda a aula.
Tente de novo sem espera entre tentativas é a forma mais comum e a mais destrutiva. A placa que reenvia a cada dois segundos transforma uma queda de dez segundos em cinquenta envios, e quando o servidor volta ele recebe cinquenta mensagens de uma vez, tenta gravar todas, e cai de novo. O sistema entra em um ciclo em que a cura causa a doença, e o professor mede isso: a fila tinha dez mensagens, e a primeira tentativa de cura a transformou em cem.
A espera entre tentativas é o que separa a cura da doença. A ideia é simples: se o servidor está fora, esperar antes de tentar aumenta a chance de que ele já tenha voltado, e reduz a pressão sobre o servidor quando ele volta. O número da espera é uma decisão de projeto, e o professor escreve duas regras:
- Aumenta a cada tentativa. Tentar imediatamente, depois esperar pouco, depois esperar mais.
- Tem um teto. Mesmo que a tentativa cinco espere cinco minutos, existe um limite escrito.
O número de tentativas é o teto de tentativas, e ele é o que decide o custo máximo de um trabalho que nunca vai dar certo. Cada tentativa custa: uma conexão, uma transação, um log de erro. A soma é o custo de um trabalho perdido. A equipe escolhe esse número com a mesma régua do dia 5: trabalho que costuma dar certo na primeira vai receber três; trabalho que depende de serviço externo pode receber cinco.
Dar up depois de N tentativas é o nome do que acontece quando o número é atingido, e ele tem nome próprio em português: abandonar o trabalho. O sistema para de tentar, marca o trabalho como falhou com a mensagem do último erro, e devolve a resposta ao usuário que estava esperando. O que o sistema não faz é ficar em silêncio: um trabalho que falhou e não foi registrado é o pior resultado possível, porque parece um trabalho que nunca foi aceito.
O efeito duplicado aparece aqui e é o que o professor quer mostrar na segunda metade: um trabalho que roda dez vezes por causa do retry e que produz efeito externo dez vezes. Relatório enviado por e-mail dez vezes. Cobrança registrada dez vezes. Leitura gravada dez vezes. O retry é uma multiplicação de tentativas, e efeito externo sem proteção é multiplicação de efeito.
Backoff exponencial e o jitter que vem junto
O backoff exponencial é a regra da espera que dobra a cada tentativa. O nome é a definição: o intervalo de espera é exponencial no número da tentativa, normalmente um fator de dois, com teto.
O que o backoff exponencial resolve não é a paciência do cliente. É a sincronização. A razão pela qual a espera dobra está na situação que a aulaopened com a placa reenviando: várias mensagens que falharam ao mesmo tempo voltam a tentar ao mesmo tempo, e se a espera for a mesma para todas, elas continuam juntas para sempre. A espera exponencial desfasa o grupo, porque cada tentativa tem um intervalo diferente, e as tentativas se espalham pelo tempo.
A tabela da aula é a sequência que a turma implementa, e os números são reais:
| Tentativa | Espera antes | Momento em que tenta |
|---|---|---|
| 1 | 0 | agora |
| 2 | 2 s | 2 s |
| 3 | 4 s | 6 s |
| 4 | 8 s | 14 s |
| 5 | 16 s | 30 s |
| 6 | 32 s | 62 s |
O jitter é o passo que ninguém dá e que resolve o resto do problema. Com backoff exponencial sem jitter, a fila de mil mensagens que caiu junto continua caindo junta: cada mensagem espera o dobro, mas todas esperam o mesmo. O jitter adiciona uma parcela aleatória à espera — de 0% a 100% do intervalo, por exemplo — e o resultado é que as mil mensagens se espalham pela janela inteira em vez de ficarem concentradas no começo.
O que o jitter custa e o que o professor mede: a espera real fica entre o mínimo e o máximo do intervalo. Com 2 s + aleatório(0, 2), a segunda tentativa acontece entre 2 e 4 segundos. A última tentativa da tabela, com 32 segundos, acontece entre 32 e 64. É uma espera pior no caso médio e muito melhor no caso de mil mensagens simultâneas, e o critério de escolha é o tamanho da fila, não o conforto de uma mensagem.
O backoff exponencial com teto tem três números que precisam estar escritos no projeto, e o professor pede os três:
- Espera inicial — quantos segundos antes da segunda tentativa.
- Fator — o multiplicador, normalmente dois.
- Teto — a espera máxima, e o número máximo de tentativas.
Os três juntos descrevem a curva completa de custo. Sem o teto, a tentativa dez espera horas; com o teto, ela espera o mesmo que a tentativa cinco. E o que o professor escreve na lousa é a frase que resume o trade-off: retry sem teto é uma fila que nunca drena; retry sem jitter é uma fila que cai de novo junto.
Trabalho duplicado e a garantia que explica ele
A garantia de entrega pelo menos uma vez da aula 1 é a razão de o trabalho duplicado existir, e ela precisa ser dita de novo aqui com a consequência prática: a fila pode entregar a mesma mensagem mais de uma vez, e não há como evitar isso sem perder trabalho.
O cenário que o professor demonstra tem cinco passos, e a turma precisa descrevê-los de memória no item 5:
- O worker retira a mensagem da fila.
- Ele processa: grava a linha no banco, faz o efeito externo, marca o trabalho como concluído no registro.
- Ele tenta confirmar, e a conexão cai antes de o
ackchegar à fila. - A fila não recebeu confirmação e reentrega a mesma mensagem.
- O worker processa de novo, e o efeito acontece de novo.
O resultado é trabalho duplicado com efeito duplicado, e a equipe olha o relatório enviado por e-mail duas vezes e conclui que alguém clicou duas vezes. A repetição não é de ninguém: é do protocolo.
O que a fila tem que oferecer para evitar esse cenário é exatamente uma vez, o que é mais caro e mais difícil: exige que a fila e o consumidor compartilhem estado, e que o consumidor confirme de forma que o estado nunca fique inconsistente. A fila usada na aula oferece pelo menos uma vez, e o desenho correto do sistema assume isso e corrige na aplicação.
A correção é a chave de idempotência aplicada ao trabalho, e ela tem a mesma forma do dia 1: o identificador é gerado quando o trabalho é criado e viaja com a mensagem. O consumidor, ao receber a mensagem:
- Tenta gravar o resultado com aquela chave.
- Recebe recusa de unicidade do banco.
- Entende como sucesso: o efeito já aconteceu.
- Só então confirma a fila.
O efeito duplicado que sobra é o que não passa pelo banco — o e-mail enviado, a chamada de API externa, a linha em outro sistema. Para esses, a chave precisa ser passada junto, ou o resultado precisa ser guardado antes do efeito. O professor explica a ordem: gravar a intenção antes de executar o efeito. Se o processo morre entre as duas coisas, a próxima tentativa vê a intenção gravada e não repete o efeito.
Idempotência aplicada, e o envio duplicado da placa
A idempotência é a propriedade de poder executar duas vezes com o resultado igual ao de uma. Não duplicar não é outra coisa: é a consequência da idempotência quando o efeito é externo. E a aula é onde as duas se encontram, porque o trabalho da fila e a leitura da placa têm a mesma estrutura.
A chave de idempotência do trabalho é o id do trabalho, e a do envio da placa é o identificador único da leitura. As duas são geradas na criação da operação, e as duas viajam dentro da mensagem. A regra é a mesma e é a frase que a turma leva do dia 1: quem inicia a operação gera a chave.
O envio duplicado da placa é o caso concreto, e ele tem duas causas que o professor separa:
- A placa reenvia porque o
acknão chegou — mesmo envio, mesmo identificador. - O worker reentrega porque a confirmação não chegou — mesma mensagem, mesmo identificador.
Nos dois casos o identificador é o mesmo, e nos dois casos a restrição de unicidade do banco resolve. É por isso que o teste do item 7 é o mesmo do dia 1 com outro nome, e o professor faz a turma perceber que não é coincidência: é o mesmo mecanismo aparecendo em duas camadas.
O que a idempotência não cobre, e que o professor escreve na lousa como limite honesto: ela cobre o banco. Ela não cobre o efeito que não passa pelo banco, e não cobre o relógio — se a chave é gerada com base em tempo e o tempo volta, a chave volta. O número de tentativas também não é coberto: a idempotência garante que rodar de novo não duplica, não que rodar de novo é seguro para o recurso externo.
O timebox do retry completa o desenho, e é a regra final que a equipe escreve: o retry tem número de tentativas e tem limite de tempo total. Uma tarefa que falha em 0,1 s dezenove vezes dá up na décima nona, num segundo; uma que falha em oito segundos dezenove vezes leva duas minutos. O número de tentativas limita a quantidade; o limite de tempo limita a demora. Os dois juntos é o que impede que uma fila drenando trabalho morto ocupe a máquina a noite inteira.
Atividade
Montagem:
LEDda placa noGPIO2, com o resistor de 330 ohm e o jumper para oGND.- Cabo USB conectado, monitor serial em 115200.
- Servidor Node rodando, com a tabela de trabalhos do dia anterior e o
iddisponível. - Folha de papel com a tabela de tentativas e as esperas.
- Escreva a tabela de tentativas do seu projeto: tentativa, espera antes, e momento em que acontece. Use espera inicial de dois segundos, fator dois e teto de trinta e dois segundos. Em que momento ocorre a quinta tentativa?
- Grave o sketch da resolução e rode. Copie no caderno a sequência de tentativas com o tempo entre elas e o instante em que o sistema dar up depois de N tentativas. O número da tela bate com a sua tabela?
- Remova o jitter do seu código e rode de novo com dez mensagens falhando ao mesmo tempo. Conte quantas tentativas chegaram no mesmo instante. Depois devolva o jitter e conte de novo. O que mudou?
- Escreva os três números do projeto: espera inicial, fator e teto. Some o tempo total até o número de tentativas máximo. Quanto tempo um trabalho morto ocupa a máquina antes de ser abandonado?
- Reproduza o trabalho duplicado: processe o trabalho, marque-o como concluído e caia antes de confirmar. Quantas vezes o efeito aconteceu? Escreva os cinco passos do cenário na sua folha, de memória.
- Aplique a chave de idempotência no seu worker e repita o passo 5. Quantas linhas há no banco agora? O que o worker fez ao receber a recusa de unicidade, e por que isso é sucesso e não erro?
- Reproduza o envio duplicado da placa: envie a mesma leitura duas vezes com o mesmo identificador e conte as linhas. Depois envie duas leituras com identificadores diferentes e o mesmo valor. Quantas linhas há? O que cada um dos dois testes prova?
- Escreva no seu projeto a intenção antes do efeito: onde o registro é gravado antes de o e-mail ser enviado, e o que o worker consulta quando o processo morreu no meio. Escreva a frase que explica por que isso não duplica.
Nota: 12 pontos. Critério de fim: a tabela de tentativas escrita, os dois números do item 3 — com e sem jitter — e os dois contadores de linha dos testes do item 7.
Resolucao
O sketch da aula está em codigo/t3/dia07/aula2.ino.
#include <Arduino.h> #include <esp_timer.h> // =========================================================================== // dia 7, aula 2: retry com espera, e o trabalho que roda duas vezes. // // A placa NAO tem fila e NAO tem servidor. O sketch reproduz a CURVA de // tentativa do worker: quantas vezes tenta, quanto espera entre uma e outra, // e o instante em que desiste. // // E reproduz tambem o trabalho duplicado: o worker processa, marca como // concluido, e "cai" antes de confirmar a fila. A fila reentrega. A chave de // idempotencia e o que impede o efeito de acontecer duas vezes. // =========================================================================== const int PIN_LED = 2; const unsigned long INTERVALO_MS = 3000; // --- os tres numeros da curva de retry, que o projeto tem que ter escritos --- // A espera inicial: quantos segundos antes da segunda tentativa. const int ESPERA_INICIAL_S = 2; // O fator: o multiplicador do backoff exponencial. const int FATOR = 2; // O teto de tentativas: o numero de tentativas antes de abandonar. const int MAX_TENTATIVAS = 4; // O teto da espera: mesmo na tentativa dez, espera este valor, nao mais. const int TETO_ESPERA_S = 32; // O intervalo da prova do jitter: a espera real fica entre ESPERA e ESPERA+. // Com jitter zero, todas as mensagens que falharam juntas voltam juntas. const int INTERVALO_JITTER_S = 4; // --------------------------------------------------------------------------- // A TABELA DE TENTATIVAS. O que o professor pede para a turma implementar: // numero da tentativa, quanto espera antes, e o instante em que acontece. // --------------------------------------------------------------------------- int64_t esperaAntes(int tentativa) { if (tentativa <= 1) return 0; // a primeira e agora int64_t espera = ESPERA_INICIAL_S; for (int i = 2; i < tentativa; i++) espera *= FATOR; if (espera > TETO_ESPERA_S) espera = TETO_ESPERA_S; return espera; } // O jitter e a parcela aleatoria. Sem ele, o backoff exponencial desfasa o // grupo em cinco mensagens e nao desfasa em mil. int64_t esperaComJitter(int tentativa) { return esperaAntes(tentativa) + random(0, INTERVALO_JITTER_S + 1); // 0 ate o intervalo } // --------------------------------------------------------------------------- // O TRABALHO. A chave de idempotencia nasce aqui, no momento em que o // trabalho e criado, e viaja com a mensagem. Quem inicia a operacao gera a // chave: e a regra do dia 1, aplicada ao trabalho. // --------------------------------------------------------------------------- struct Trabalho { unsigned long chave; // chave de idempotencia const char* nome; int tentativas; const char* estado; // pendente | concluido | falhou int efeitosAplicados; // quantas vezes o efeito aconteceu }; Trabalho TRABALHOS[4]; int totalTrabalhos = 0; // A base do banco com restricao de unicidade sobre a chave. E a unica coisa // que impede o efeito duplicado quando a fila reentrega. const int MAX_EFETOS_POR_TRABALHO = 1; // --------------------------------------------------------------------------- // O EFEITO EXTERNO, com a INTENCAO GRAVADA ANTES. Se o processo morrer // entre a intencao e o efeito, a proxima tentativa ve a intencao gravada e // nao repete. E o que fecha o efeito que nao passa pelo banco. // --------------------------------------------------------------------------- bool aplicarEfeito(Trabalho& t) { if (t.efeitosAplicados >= MAX_EFETOS_POR_TRABALHO) { Serial.printf(" chave %lu já tem efeito gravado: não aplicar de novo\n", t.chave); return false; // idempotencia: o efeito nao se repete } Serial.printf(" chave %lu: registrando a intenção e aplicando o efeito\n", t.chave); t.efeitosAplicados++; return true; } void criarTrabalho(const char* nome, unsigned long chave) { TRABALHOS[totalTrabalhos].chave = chave; TRABALHOS[totalTrabalhos].nome = nome; TRABALHOS[totalTrabalhos].tentativas = 0; TRABALHOS[totalTrabalhos].estado = "pendente"; TRABALHOS[totalTrabalhos].efeitosAplicados = 0; totalTrabalhos++; } // --------------------------------------------------------------------------- // O WORKER COM RETRY. Cada falha conta uma tentativa e espera com backoff // exponencial mais jitter. Quando o numero de tentativas acaba, o trabalho // e abandonado: marcado como falhou, com o ultimo erro. // --------------------------------------------------------------------------- bool tentarComRetry(Trabalho& t) { int64_t acumulado = 0; while (t.tentativas < MAX_TENTATIVAS) { t.tentativas++; Serial.printf(" tentativa %d/%d, esperando %lld ms antes\n", t.tentativas, MAX_TENTATIVAS, (long long)esperaComJitter(t.tentativas)); acumulado += esperaComJitter(t.tentativas); // A falha simulada: no projeto, e o servidor fora ou a rede sem resposta. bool sucesso = (t.tentativas >= 3); if (sucesso) { Serial.printf(" tentativa %d deu certo\n", t.tentativas); return true; } Serial.printf(" tentativa %d falhou; volta em %lld ms\n", t.tentativas, (long long)esperaComJitter(t.tentativas + 1)); } Serial.printf(" %s: dar up depois de %d tentativas, ultimo erro foi " "'servidor sem resposta'\n", t.nome, MAX_TENTATIVAS); return false; } void setup() { pinMode(PIN_LED, OUTPUT); Serial.begin(115200); delay(200); randomSeed(42); Serial.println(); Serial.println("=== dia 7, aula 2: retry com espera e o trabalho que roda duas vezes ==="); Serial.println("Backoff exponencial, jitter, e a chave de idempotência."); Serial.println(); Serial.println("--- a curva de tentativas, como o professor escreve na lousa ---"); Serial.printf(" espera inicial %d s, fator %d, teto de espera %d s, " "máximo de tentativas %d\n", ESPERA_INICIAL_S, FATOR, TETO_ESPERA_S, MAX_TENTATIVAS); Serial.println(" tentativa espera antes instante"); int64_t acumulado = 0; for (int t = 1; t <= MAX_TENTATIVAS + 1; t++) { Serial.printf(" %8d %10lld s %8lld s\n", t, (long long)esperaAntes(t), (long long)acumulado); acumulado += esperaAntes(t); } Serial.println(" a espera dobra, o teto segura, e o jitter espalha."); Serial.println(); criarTrabalho("relatorio do dia", 5001); criarTrabalho("recalcular grafico", 5002); } void loop() { digitalWrite(PIN_LED, HIGH); Serial.println("--- o worker tentando um trabalho, com espera ---"); for (int i = 0; i < totalTrabalhos; i++) { Serial.printf(" trabalho: %s (chave de idempotência %lu)\n", TRABALHOS[i].nome, TRABALHOS[i].chave); if (tentarComRetry(TRABALHOS[i])) { TRABALHOS[i].estado = "concluido"; } else { TRABALHOS[i].estado = "falhou"; } Serial.printf(" estado final: %s, em %d tentativa(s)\n", TRABALHOS[i].estado, TRABALHOS[i].tentativas); } Serial.println(); Serial.println("--- o trabalho duplicado: a fila reentrega ---"); Serial.println(" 1. o worker retira a mensagem"); Serial.println(" 2. processa: grava o resultado e marca concluído"); Serial.println(" 3. tenta confirmar a fila e cai ANTES do ack"); Serial.println(" 4. a fila não recebeu nada e reentrega a mesma mensagem"); Serial.println(" 5. o worker processa de novo"); Serial.println(); Trabalho& t = TRABALHOS[0]; aplicarEfeito(t); aplicarEfeito(t); // a reentrega Serial.print(" efeitos aplicados ao trabalho 1: "); Serial.println(t.efeitosAplicados); Serial.println(" sem a chave de idempotência, este número seria 2, e o"); Serial.println(" relatório teria saído duas vezes."); Serial.println(); Serial.println("--- os números da curva, medidos ---"); Serial.printf(" tempo total de espera até o máximo de tentativas: "); int64_t total = 0; for (int i = 1; i <= MAX_TENTATIVAS; i++) total += esperaAntes(i); Serial.print(total); Serial.println(" s"); Serial.print(" total com jitter, nesta execução: "); int64_t comJitter = 0; for (int i = 1; i <= MAX_TENTATIVAS; i++) comJitter += esperaComJitter(i); Serial.print(comJitter); Serial.println(" s"); Serial.println(" e esse é o tempo que um trabalho morto ocupa a máquina."); Serial.println(); digitalWrite(PIN_LED, LOW); delay(INTERVALO_MS); }
Por que assim e não de outro jeito. As três constantes do topo — ESPERA_INICIAL_S, FATOR e MAX_TENTATIVAS — são os três números que o professor exige que o projeto escreva, e elas estão separadas do código porque são configuração e não lógica. O item 1 da atividade pede exatamente a tabela que a função esperaAntes imprime, e a função existe para que a tabela seja gerada e não digitada: mudar o fator de dois para três muda a tabela inteira.
A esperaAntes faz o exponencial por multiplicação em laço, e não com pow. O motivo é didático e o professor diz: o laço mostra que cada tentativa multiplica a espera pela anterior, e pow esconde exatamente isso atrás de uma função. O teto vem logo depois, e é o que impede a tentativa dez de esperar uma hora — a condição é uma comparação, e ela está escrita na ordem em que o professor a explica na lousa.
A esperaComJitter soma random(0, INTERVALO_JITTER_S + 1), e o +1 é o detalhe que o professor pede para ler. Sem ele, o jitter nunca chega ao valor do intervalo, e a janela real seria menor do que a escrita no projeto. É a mesma armadilha de limite de laço do dia 1, e aparece aqui porque é o mesmo tipo de erro: o limite que se escreve no projeto e o limite que o código usa precisam ser o mesmo número.
A MAX_EFETOS_POR_TRABALHO igual a 1 é a idempotência na forma mais curta que existe: o efeito é aplicado uma vez e só. A função aplicarEfeito verifica antes de agir, e é a ordem que o professor chama de intenção antes do efeito — o registro acontece antes do efeito externo, e se o processo morrer entre os dois, a próxima tentativa vê que já foi.
A segunda chamada a aplicarEfeito no loop é a reentrega sendo executada na frente da turma, e a diferença entre as duas saídas é o argumento inteiro da aula: a primeira imprime "registrando a intenção", a segunda imprime "já tem efeito gravado: não aplicar de novo". A linha do meio da tela é o que a equipe precisa ter no código do worker real, e ela só aparece porque a segunda chamada usa a mesma chave.
O randomSeed(42) no setup é para a demonstração ser reproduzível. Sem ele, cada execução da placa mostraria um número diferente de jitter, e a turma não conseguiria comparar duas execuções. O professor explica que isso é escolha de aula: em produção o jitter é genuinamente aleatório, e o seed fixo é só para o professor poder dizer "olha, deu três" e a turma conferir.
O tentarComRetry tem o while com t.tentativas < MAX_TENTATIVAS e não um while (true). A diferença é o ponto que fecha a seção de dar up depois de N tentativas: o laço com condição de saída é o que permite abandonar o trabalho, gravar o estado falhou e devolver a mensagem do último erro. Um laço infinito com retry é uma máquina ocupada até o fim do dia com trabalho que nunca vai dar certo.
O LED acende durante as tentativas e apaga no intervalo. O intervalo de três segundos é menor que o das outras aulas porque a curva de tentativas tem cinco linhas e a turma precisa reler o bloco antes da próxima volta; o delay do intervalo não acumula com as esperas do retry, que são calculadas e impressas e não executadas de verdade — e o professor avisa que no código real elas seriam um delay de verdade, ou um agendamento na fila.
Criterios de correcao
| Critério | Pontos |
|---|---|
| A tabela de tentativas escrita, com espera antes e instante de cada uma | 2 pontos |
| O número de tentativas e o dar up, com o instante em que o sistema desiste | 2 pontos |
| Os dois contadores do teste com e sem jitter | 2 pontos |
| Os três números do projeto escritos, com o tempo total de um trabalho morto | 2 pontos |
| Os cinco passos do trabalho duplicado, escritos de memória | 2 pontos |
| A chave de idempotência aplicada e o contador de efeitos depois da reentrega | 1 ponto |
| O teste do envio duplicado da placa, com os dois contadores | 1 ponto |
Erros comuns
| Erro | Como aparece | Correção |
|---|---|---|
| Reenviar em intervalo fixo | A placa manda a mesma leitura a cada dois segundos | "Esperar em backoff exponencial desfasa o grupo. Com intervalo fixo, quando o servidor volta chegam todas as mensagens de uma vez e ele cai de novo." |
| Retry sem teto de tentativas | O trabalho tenta a noite inteira e nunca desiste | "Escreva o número máximo de tentativas e o que o sistema faz ao atingir: abandonar, marcar falhou e devolver o erro." |
| Backoff sem teto de espera | A tentativa dez espera horas | "O teto corta a espera em algum valor escrito. Sem ele, a curva cresce até a fila parar de drenar sozinha." |
| Backoff sem jitter | Mil mensagens que caíram juntas voltam todas no mesmo instante | "O jitter é a parcela aleatória. Sem ele o grupo se desfasa em cinco e continua junto em mil." |
| Registrar o efeito depois de aplicá-lo | O processo morre entre os dois e o efeito repete | "Grave a intenção antes do efeito. Se morrer no meio, a próxima tentativa vê a intenção e não repete." |
| Tratar recusa de unicidade como erro | O worker fica reentregando em laço até cair | "Recusa de unicidade é sucesso: o efeito já aconteceu. Confirme a fila e siga." |
| Chave gerada no processamento | Cada reentrega gera chave nova e duplica | "A chave de idempotência nasce na criação da operação. Quem inicia gera a chave." |
| Retry só com limite de tempo | Um trabalho que falha em 0,1 s ocupa a máquina a noite toda | "Limite de tempo sem limite de tentativas não impede o laço rápido. Os dois juntos limitam quantidade e demora." |
Desafio extra
Implemente no seu projeto a curva completa de retry com os três números escritos e meça o tempo total de um trabalho que falha em todas as tentativas, nos dois casos: com jitter e sem jitter. Repita o teste com uma, dez e cem mensagens que falham ao mesmo tempo, e escreva em cada caso quantas mensagens chegaram no mesmo instante. Em seguida, monte o cenário de trabalho duplicado com um efeito que não passa pelo banco — no projeto, um envio de e-mail ou uma chamada de API — e escreva as três linhas que impedem a duplicação desse efeito. Meça de novo o tempo total com essas três linhas no lugar. A pergunta que o professor espera de resposta: em que tamanho de fila o jitter deixa de ser um detalhe e passa a ser o que segura a recuperação, e o que muda no painel de operação quando a taxa de trabalho duplicado deixa de ser zero?
>A resolucao, compilada
// Aula 2 do dia 7 do 3o trimestre de ESP32 e IoT. // Gerado a partir da secao ## Resolucao de conteudo/t3/dia07/aula2.md. // Se mudar o codigo, mude no .md e regere: o .ino e copia do .md. #include <Arduino.h> #include <esp_timer.h> // =========================================================================== // dia 7, aula 2: retry com espera, e o trabalho que roda duas vezes. // // A placa NAO tem fila e NAO tem servidor. O sketch reproduz a CURVA de // tentativa do worker: quantas vezes tenta, quanto espera entre uma e outra, // e o instante em que desiste. // // E reproduz tambem o trabalho duplicado: o worker processa, marca como // concluido, e "cai" antes de confirmar a fila. A fila reentrega. A chave de // idempotencia e o que impede o efeito de acontecer duas vezes. // =========================================================================== const int PIN_LED = 2; const unsigned long INTERVALO_MS = 3000; // --- os tres numeros da curva de retry, que o projeto tem que ter escritos --- // A espera inicial: quantos segundos antes da segunda tentativa. const int ESPERA_INICIAL_S = 2; // O fator: o multiplicador do backoff exponencial. const int FATOR = 2; // O teto de tentativas: o numero de tentativas antes de abandonar. const int MAX_TENTATIVAS = 4; // O teto da espera: mesmo na tentativa dez, espera este valor, nao mais. const int TETO_ESPERA_S = 32; // O intervalo da prova do jitter: a espera real fica entre ESPERA e ESPERA+. // Com jitter zero, todas as mensagens que falharam juntas voltam juntas. const int INTERVALO_JITTER_S = 4; // --------------------------------------------------------------------------- // A TABELA DE TENTATIVAS. O que o professor pede para a turma implementar: // numero da tentativa, quanto espera antes, e o instante em que acontece. // --------------------------------------------------------------------------- struct Trabalho { unsigned long chave; // chave de idempotencia const char* nome; int tentativas; const char* estado; // pendente | concluido | falhou int efeitosAplicados; // quantas vezes o efeito aconteceu }; int64_t esperaAntes(int tentativa) { if (tentativa <= 1) return 0; // a primeira e agora int64_t espera = ESPERA_INICIAL_S; for (int i = 2; i < tentativa; i++) espera *= FATOR; if (espera > TETO_ESPERA_S) espera = TETO_ESPERA_S; return espera; } // O jitter e a parcela aleatoria. Sem ele, o backoff exponencial desfasa o // grupo em cinco mensagens e nao desfasa em mil. int64_t esperaComJitter(int tentativa) { return esperaAntes(tentativa) + random(0, INTERVALO_JITTER_S + 1); // 0 ate o intervalo } // --------------------------------------------------------------------------- // O TRABALHO. A chave de idempotencia nasce aqui, no momento em que o // trabalho e criado, e viaja com a mensagem. Quem inicia a operacao gera a // chave: e a regra do dia 1, aplicada ao trabalho. // --------------------------------------------------------------------------- Trabalho TRABALHOS[4]; int totalTrabalhos = 0; // A base do banco com restricao de unicidade sobre a chave. E a unica coisa // que impede o efeito duplicado quando a fila reentrega. const int MAX_EFETOS_POR_TRABALHO = 1; // --------------------------------------------------------------------------- // O EFEITO EXTERNO, com a INTENCAO GRAVADA ANTES. Se o processo morrer // entre a intencao e o efeito, a proxima tentativa ve a intencao gravada e // nao repete. E o que fecha o efeito que nao passa pelo banco. // --------------------------------------------------------------------------- bool aplicarEfeito(Trabalho& t) { if (t.efeitosAplicados >= MAX_EFETOS_POR_TRABALHO) { Serial.printf(" chave %lu já tem efeito gravado: não aplicar de novo\n", t.chave); return false; // idempotencia: o efeito nao se repete } Serial.printf(" chave %lu: registrando a intenção e aplicando o efeito\n", t.chave); t.efeitosAplicados++; return true; } void criarTrabalho(const char* nome, unsigned long chave) { TRABALHOS[totalTrabalhos].chave = chave; TRABALHOS[totalTrabalhos].nome = nome; TRABALHOS[totalTrabalhos].tentativas = 0; TRABALHOS[totalTrabalhos].estado = "pendente"; TRABALHOS[totalTrabalhos].efeitosAplicados = 0; totalTrabalhos++; } // --------------------------------------------------------------------------- // O WORKER COM RETRY. Cada falha conta uma tentativa e espera com backoff // exponencial mais jitter. Quando o numero de tentativas acaba, o trabalho // e abandonado: marcado como falhou, com o ultimo erro. // --------------------------------------------------------------------------- bool tentarComRetry(Trabalho& t) { int64_t acumulado = 0; while (t.tentativas < MAX_TENTATIVAS) { t.tentativas++; Serial.printf(" tentativa %d/%d, esperando %lld ms antes\n", t.tentativas, MAX_TENTATIVAS, (long long)esperaComJitter(t.tentativas)); acumulado += esperaComJitter(t.tentativas); // A falha simulada: no projeto, e o servidor fora ou a rede sem resposta. bool sucesso = (t.tentativas >= 3); if (sucesso) { Serial.printf(" tentativa %d deu certo\n", t.tentativas); return true; } Serial.printf(" tentativa %d falhou; volta em %lld ms\n", t.tentativas, (long long)esperaComJitter(t.tentativas + 1)); } Serial.printf(" %s: dar up depois de %d tentativas, ultimo erro foi " "'servidor sem resposta'\n", t.nome, MAX_TENTATIVAS); return false; } void setup() { pinMode(PIN_LED, OUTPUT); Serial.begin(115200); delay(200); randomSeed(42); Serial.println(); Serial.println("=== dia 7, aula 2: retry com espera e o trabalho que roda duas vezes ==="); Serial.println("Backoff exponencial, jitter, e a chave de idempotência."); Serial.println(); Serial.println("--- a curva de tentativas, como o professor escreve na lousa ---"); Serial.printf(" espera inicial %d s, fator %d, teto de espera %d s, " "máximo de tentativas %d\n", ESPERA_INICIAL_S, FATOR, TETO_ESPERA_S, MAX_TENTATIVAS); Serial.println(" tentativa espera antes instante"); int64_t acumulado = 0; for (int t = 1; t <= MAX_TENTATIVAS + 1; t++) { Serial.printf(" %8d %10lld s %8lld s\n", t, (long long)esperaAntes(t), (long long)acumulado); acumulado += esperaAntes(t); } Serial.println(" a espera dobra, o teto segura, e o jitter espalha."); Serial.println(); criarTrabalho("relatorio do dia", 5001); criarTrabalho("recalcular grafico", 5002); } void loop() { digitalWrite(PIN_LED, HIGH); Serial.println("--- o worker tentando um trabalho, com espera ---"); for (int i = 0; i < totalTrabalhos; i++) { Serial.printf(" trabalho: %s (chave de idempotência %lu)\n", TRABALHOS[i].nome, TRABALHOS[i].chave); if (tentarComRetry(TRABALHOS[i])) { TRABALHOS[i].estado = "concluido"; } else { TRABALHOS[i].estado = "falhou"; } Serial.printf(" estado final: %s, em %d tentativa(s)\n", TRABALHOS[i].estado, TRABALHOS[i].tentativas); } Serial.println(); Serial.println("--- o trabalho duplicado: a fila reentrega ---"); Serial.println(" 1. o worker retira a mensagem"); Serial.println(" 2. processa: grava o resultado e marca concluído"); Serial.println(" 3. tenta confirmar a fila e cai ANTES do ack"); Serial.println(" 4. a fila não recebeu nada e reentrega a mesma mensagem"); Serial.println(" 5. o worker processa de novo"); Serial.println(); Trabalho& t = TRABALHOS[0]; aplicarEfeito(t); aplicarEfeito(t); // a reentrega Serial.print(" efeitos aplicados ao trabalho 1: "); Serial.println(t.efeitosAplicados); Serial.println(" sem a chave de idempotência, este número seria 2, e o"); Serial.println(" relatório teria saído duas vezes."); Serial.println(); Serial.println("--- os números da curva, medidos ---"); Serial.printf(" tempo total de espera até o máximo de tentativas: "); int64_t total = 0; for (int i = 1; i <= MAX_TENTATIVAS; i++) total += esperaAntes(i); Serial.print(total); Serial.println(" s"); Serial.print(" total com jitter, nesta execução: "); int64_t comJitter = 0; for (int i = 1; i <= MAX_TENTATIVAS; i++) comJitter += esperaComJitter(i); Serial.print(comJitter); Serial.println(" s"); Serial.println(" e esse é o tempo que um trabalho morto ocupa a máquina."); Serial.println(); digitalWrite(PIN_LED, LOW); delay(INTERVALO_MS); }
Sem saída de compilação gravada. Rode python3 validar.py -t 3 dia07 aula2.
