Eliminando o copia-e-cola dos jobs de sindicação do blog-manager
Esse trabalho veio de um spike anterior, e a premissa era simples: oito jobs em segundo plano no blog-manager tinham degenerado numa bagunça de copia-e-cola. Todo job que publica algo no Dev.to, no Medium ou na newsletter, mais o scanner de blog do GitHub, carrega o mesmo esqueleto de broadcast do Turbo. Três deles constroem um cliente Listmonk com as mesmas seis linhas, exatamente iguais. E dois deles, os jobs de poll que checam se um artigo do Dev.to ou uma campanha do Listmonk realmente saiu, tinham divergido silenciosamente em como lidam com desistir.
O que eu estava tentando fazer
Extrair as partes duplicadas para código compartilhado sem mudar o comportamento do caminho feliz, e corrigir o único bug real escondido na duplicação: o job de poll do Dev.to para silenciosamente depois de 12 tentativas e deixa o post em devto_status: :draft para sempre, sem erro nenhum e sem jeito de tentar de novo pela interface. O job de poll equivalente da newsletter faz a coisa sensata e se marca como falho com uma mensagem. Mesma forma, mesmas constantes, final diferente.
O que eu construí
Três peças. Broadcastable, uma concern de job com um único método, broadcast_record(record) - em vez de fixar um caminho de partial por job, ele chama record.to_partial_path, a mesma primitiva que o próprio render record do Rails usa, o que significa que a concern não pode ficar dessincronizada da camada de view do jeito que seis caminhos de partial construídos separadamente por string acabariam ficando. PollJob, uma classe base da qual os dois jobs de poll de status agora herdam - ela é dona do loop, do backoff, do limite de tentativas e da política de erro, e cada subclasse só responde a um punhado de perguntas: esse registro já está resolvido, como você faz o poll dele, o que “falhou” significa para esse registro, quais erros valem a pena tentar de novo versus desistir na hora. E Syndication::ClientFactory, um módulo com um método por provedor (devto, listmonk, medium_bridge) para que o código de leitura de credenciais que antes vivia em cinco métodos build_client diferentes agora viva num só lugar.
Decisões que tomei e por quê
A ordem dos rescues em PollJob quase me pegou. Devto::Client::AuthError é uma subclasse de Devto::Client::Error, e eu tinha escrito a cláusula “tentar de novo em erro transitório” antes da cláusula “falhar imediatamente em erro de autenticação.” Em Ruby, cláusulas de rescue são checadas de cima para baixo, então a cláusula mais ampla Error teria engolido silenciosamente todo AuthError e reagendado 12 vezes em vez de falhar rápido. Eu percebi isso rastreando o que “erro de autenticação” realmente significa em tempo de execução, não porque um teste pegou, o que é um pouco inquietante. Reordenei para que a classe de erro mais específica seja checada primeiro.
Também fiz o rescue do poll não relançar o erro, de propósito, o que é o oposto do que os jobs de publicação fazem. Os jobs de publicação relançam para que a maquinaria retry_on/discard_on do ActiveJob assuma o controle. Mas os jobs de poll já rodam seu próprio loop de reagendamento com seu próprio contador de tentativas. Se eu deixasse um erro capturado também disparar o retry_on, eu teria dois mecanismos de retry independentes contando contra o mesmo problema, e o limite de tentativas efetivo perderia o sentido.
Deixei duas coisas explicitamente fora do escopo mesmo com a issue original mencionando ambas: memoizar AppSetting.current (isso é ticket próprio, e um memo ingênuo teria quebrado todo teste que chama AppSetting.current.update! no setup), e renomear Post#article_entry para algo mais claro. Nenhuma das duas precisava se mover para essa refatoração fechar direito, e as duas teriam alargado o diff para território sem relação.
O que me surpreendeu
O quanto os dois jobs de poll pareciam gêmeos até o exato momento em que deixaram de parecer. Mesmo MAX_ATTEMPTS = 12. Mesma fórmula de backoff, copiada caractere por caractere: 30 * (2**(attempt - 1)) limitada a 15 minutos. Se você diffasse os dois lado a lado, veria talvez quatro linhas que realmente diferem. Mas uma dessas quatro linhas é “o que acontece quando você desiste,” e é exatamente a linha que ninguém revisa de novo depois que o primeiro job sobe e funciona. O segundo job é escrito copiando o primeiro, e o único lugar em que deveria ter copiado fielmente é o único lugar onde alguém mudou algo sem querer mandar um sinal.
A outra surpresa foi menor: os testes unitários desses jobs verificam mudanças de estado e jobs enfileirados, nunca o que de fato é renderizado no broadcast. Uma refatoração que silenciosamente produzisse o caminho de partial errado passaria por bin/rails test sem uma única falha e só quebraria no navegador. Acabei verificando to_partial_path para os três tipos de registro num console, depois disparando um broadcast real por tipo e confirmando que uma mensagem realmente chegou no solid_cable, porque essa é a única forma de de fato exercitar a derivação da qual a concern depende.
O que vem depois
O ticket de follow-up assume a memoização do AppSetting e mais alguns itens de higiene de schema e código. A interface do Dev.to ainda mostra “Publishing disabled” mesmo para um post que agora está corretamente marcado como falho depois dessa mudança, o que fica coerente (o badge diz ERROR, o texto explica que ainda não há caminho de retry) mas um botão de retry de verdade ainda depende de verificar a publicação nativa no Dev.to de ponta a ponta.
Leitura relacionada
Limpando um trimestre inteiro de código morto num único PR
Nove colunas mortas do Postiz, uma memoização em que o Rails.cache teria anulado a criptografia, três filtros Ruby movidos para SQL, e código supostamente inalcançável que não era.
O que acontece quando um job faz broadcast para ninguém
Fechando o loop da imagem de destaque: frontmatter só por inserção, uma guarda que pegou divergência real na primeira execução, e um broadcast sem ouvinte.
Uma correção de desvio de documentação que não foi tão chata quanto parecia
Três itens da auditoria que cada um virou outra coisa: uma alegação meio corrigida, uma recuperação de senha silenciosamente morta, e um e-mail de staging que apontaria para produção.
Você também pode achar útil
eSIM Airalo
eSIM de dados local para viagens - sem necessidade de trocar um SIM físico.
Este é meu link de indicação da Airalo. Você recebe um desconto no seu primeiro eSIM e eu ganho crédito da Airalo para o meu.
Saiba maisAdGuard para iOS
Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.
Como afiliado da AdGuard, ganho com compras qualificadas.
Saiba maisRackNerd VPS
Hospedagem VPS econômica para serviços leves que funcionam continuamente.
Como afiliado da RackNerd, ganho com compras qualificadas.
Saiba mais