diff --git a/README.md b/README.md index fdd7288..c46fd65 100644 --- a/README.md +++ b/README.md @@ -281,10 +281,22 @@ Quando uma demanda de um executor fica atrasada (data no passado e ainda não co - `TelegramNotifier` (`app/services/telegram_notifier.rb`) monta a mensagem e chama a API do Telegram (`sendMessage`) via `Net::HTTP` puro, sem depender de nenhuma gem adicional. - A mensagem é empática e objetiva: cita o título da demanda e há quantos dias está atrasada, sem tom de cobrança, e diz o que fazer a seguir (atualizar o status, ou avisar o líder se precisar de mais tempo/ajuda). -- Como "ficar atrasada" é um estado que muda com o tempo (não com uma ação do usuário), o envio não acontece automaticamente na aplicação — é a tarefa `bin/rails demandas:notificar_atrasos` (`lib/tasks/telegram_notifications.rake`) que precisa rodar periodicamente (ex.: 1x ao dia). No Railway, isso é feito criando um segundo serviço do tipo **Cron Job** no mesmo projeto, rodando esse comando. +- Como "ficar atrasada" é um estado que muda com o tempo (não com uma ação do usuário), o envio não acontece automaticamente na aplicação — é a tarefa `bin/rails demandas:notificar_atrasos` (`lib/tasks/telegram_notifications.rake`) que precisa rodar periodicamente. - Cada atraso é notificado **uma única vez** (campo `Demanda#atraso_notificado_em`) — se a demanda deixar de estar atrasada (data adiada ou marcada como concluída) e depois atrasar de novo, um novo aviso é enviado. - Sem `TELEGRAM_BOT_TOKEN` configurado, ou sem `telegram_chat_id` no usuário, a notificação é simplesmente pulada (não é um erro). +### Agendamento (parte do deploy, não detalhe da task) + +A tarefa não roda sozinha: **um deploy sem agendamento tem a funcionalidade desligada**, e nada na aplicação avisa isso. Uma vez por dia, em horário comercial do fuso da equipe, é a cadência para a qual a mensagem foi escrita ("está há N dias com o prazo vencido"). + +No **Railway**, isso é um segundo serviço do tipo **Cron Job** no mesmo projeto, apontando para o mesmo repositório e rodando `bin/rails demandas:notificar_atrasos`. Em `docker compose`, um `cron` do host chamando `docker compose exec web bin/rails demandas:notificar_atrasos`. + +Existe hoje uma terceira via que não existia quando a task foi escrita: desde a adoção do Solid Queue (ver "Webhooks de saída"), o projeto tem `config/recurring.yml` e um processo `worker` de pé, que agendaria isso sem scheduler externo nenhum. É a evolução natural — exigiria embrulhar a task num job — e ainda não foi feita. + +**A garantia é "no máximo uma vez", e ela é do banco, não da disciplina de quem agenda.** A task reivindica cada demanda com um `UPDATE ... WHERE atraso_notificado_em IS NULL` **antes** de enviar: quem escreve a linha é quem envia. Duas execuções simultâneas — cron disparado duas vezes, retry do scheduler, uma réplica a mais — não notificam a mesma demanda duas vezes, e um processo que morra depois do envio não faz a execução seguinte repetir a mensagem. + +O preço é que uma notificação pode ser *gasta sem sair*, se o processo morrer entre a reivindicação e o envio. Uma falha declarada do envio (o Telegram respondeu erro) devolve a demanda para a próxima execução; uma queda no meio, não. Perder um lembrete numa queda é melhor do que mandar o mesmo lembrete duas vezes em toda execução concorrente. + ## Webhooks de saída Além do Telegram, o **admin** pode cadastrar webhooks genéricos em `/webhooks` — uma URL que recebe um `POST` com JSON toda vez que um dos eventos escolhidos acontece. Serve tanto pra notificar um canal de chat (Slack/Teams/Discord, apontando pra um webhook incoming deles) quanto pra disparar uma automação no-code (n8n, Knime) — o mecanismo é o mesmo, só muda quem recebe o `POST`. diff --git a/lib/tasks/telegram_notifications.rake b/lib/tasks/telegram_notifications.rake index 665cff7..4444f4a 100644 --- a/lib/tasks/telegram_notifications.rake +++ b/lib/tasks/telegram_notifications.rake @@ -5,10 +5,51 @@ # `bin/rails demandas:notificar_atrasos`. Não é chamado automaticamente # pela aplicação, porque "ficar atrasada" é um estado que só muda com a # passagem do tempo, não com uma ação do usuário — precisa de alguém -# perguntando periodicamente "o que está atrasado agora?". +# perguntando periodicamente "o que está atrasado agora?". Ver README, +# seção "Notificação de atraso via Telegram". namespace :demandas do desc 'Envia um lembrete no Telegram para o responsável de cada demanda atrasada (uma vez por atraso)' task notificar_atrasos: :environment do + # Reivindica a demanda ANTES de enviar, com um UPDATE condicional: + # quem escreve a linha é quem envia. É isso que torna "uma vez por + # atraso" — a promessa do README — verdade também fora do caminho + # feliz de execução única. + # + # Antes a ordem era selecionar, enviar, e só então gravar. Duas + # janelas de duplicação vinham daí: duas execuções simultâneas (cron + # disparado duas vezes, retry do scheduler, réplica extra) selecionavam + # a mesma demanda antes de qualquer gravação e notificavam as duas; e + # uma morte de processo entre o envio e a gravação fazia a execução + # seguinte notificar de novo. + # + # O UPDATE ... WHERE atraso_notificado_em IS NULL resolve as duas de + # uma vez, porque a checagem e a escrita acontecem no mesmo comando: o + # banco decide quem venceu, e só o vencedor recebe 1 linha afetada. + reivindicar = lambda do |demanda| + Demanda.where(id: demanda.id, atraso_notificado_em: nil) + .update_all(atraso_notificado_em: Time.current) == 1 + end + + # Devolve a reivindicação quando o envio falha de forma declarada + # (TelegramNotifier#notify_atraso devolveu false), para que a próxima + # execução tente de novo — é o comportamento que já existia, e não há + # motivo para perdê-lo junto com a correção da corrida. + # + # O que NÃO é devolvido: uma demanda cujo processo morra entre a + # reivindicação e o envio. Aquela notificação é gasta sem sair, e essa + # é a troca consciente de reivindicar antes de enviar. Perder um + # lembrete numa queda é melhor que mandar o mesmo lembrete duas vezes + # em toda execução concorrente. + # + # Sobra uma janela estreita: um transporte que devolva false depois de + # a mensagem ter sido entregue (timeout na leitura da resposta, por + # exemplo) faz a próxima execução notificar de novo. É estritamente + # menor que a janela de antes, e o modo de falha é o mesmo que já + # existia. + devolver = lambda do |demanda| + Demanda.where(id: demanda.id).update_all(atraso_notificado_em: nil) + end + if ENV['TELEGRAM_BOT_TOKEN'].blank? puts 'TELEGRAM_BOT_TOKEN não configurado — nada a fazer.' next @@ -31,11 +72,16 @@ namespace :demandas do next end + unless reivindicar.call(demanda) + puts "Pulei ##{demanda.id} (#{demanda.title}): outra execução reivindicou a notificação antes." + next + end + if TelegramNotifier.notify_atraso(demanda) - demanda.update_column(:atraso_notificado_em, Time.current) puts "Notifiquei ##{demanda.id} (#{demanda.title}) -> #{demanda.user.name}" else - puts "Falha ao notificar ##{demanda.id} (#{demanda.title})" + devolver.call(demanda) + puts "Falha ao notificar ##{demanda.id} (#{demanda.title}) — devolvida para a próxima execução." end end end diff --git a/spec/tasks/telegram_notifications_rake_spec.rb b/spec/tasks/telegram_notifications_rake_spec.rb index 3f51051..fe884cd 100644 --- a/spec/tasks/telegram_notifications_rake_spec.rb +++ b/spec/tasks/telegram_notifications_rake_spec.rb @@ -42,6 +42,58 @@ expect(demanda.reload.atraso_notificado_em).to be_nil end + # A marca passou a ser escrita ANTES do envio (ver o comentário na + # task): é isso que impede duas execuções simultâneas de notificarem a + # mesma demanda. Sem a devolução aqui verificada, uma falha de rede + # gastaria a notificação para sempre. + it 'devolve a demanda para a próxima execução quando o envio falha' do + responsavel = create(:user, :executor, telegram_chat_id: '123456') + demanda = create(:demanda, user: responsavel, data: 3.days.ago.to_date, status: :pendente) + + allow(TelegramNotifier).to receive(:notify_atraso).and_return(false) + task.invoke + + allow(TelegramNotifier).to receive(:notify_atraso).and_return(true) + task.reenable + task.invoke + + expect(demanda.reload.atraso_notificado_em).to be_present + end + + it 'reivindica a demanda antes de enviar, e não depois' do + responsavel = create(:user, :executor, telegram_chat_id: '123456') + create(:demanda, user: responsavel, data: 3.days.ago.to_date, status: :pendente) + + marca_no_momento_do_envio = nil + allow(TelegramNotifier).to receive(:notify_atraso) do |demanda| + marca_no_momento_do_envio = Demanda.where(id: demanda.id).pick(:atraso_notificado_em) + true + end + + task.invoke + + expect(marca_no_momento_do_envio).to be_present + end + + # A corrida de verdade: outra execução da task selecionou a mesma + # demanda antes de qualquer gravação e só agora tenta reivindicá-la. O + # UPDATE condicional é quem decide — ela precisa perder. + it 'faz uma execução concorrente perder a corrida em vez de notificar de novo' do + responsavel = create(:user, :executor, telegram_chat_id: '123456') + create(:demanda, user: responsavel, data: 3.days.ago.to_date, status: :pendente) + + linhas_da_concorrente = nil + allow(TelegramNotifier).to receive(:notify_atraso) do |demanda| + linhas_da_concorrente = Demanda.where(id: demanda.id, atraso_notificado_em: nil) + .update_all(atraso_notificado_em: Time.current) + true + end + + task.invoke + + expect(linhas_da_concorrente).to eq(0) + end + it 'não notifica de novo uma demanda que já foi notificada' do responsavel = create(:user, :executor, telegram_chat_id: '123456') create(:demanda, user: responsavel, data: 3.days.ago.to_date, status: :pendente,