Isolamento de filas do GoodJob e dyno de prioridade

TLDR: Isola as filas do GoodJob (cron/payments/notifications/default) em pools dedicados de threads e cria um process type priority no Heroku especializado na fila payments — pagamentos rodam no dyno jobs (fallback) e no priority (capacidade extra), garantindo que nunca fiquem sem worker.

Contexto

O dyno jobs em produção travou em 10 de junho com a memória estourando (191% da cota, R14). A investigação mostrou:

  1. TherapistAvailableTimes::RefreshJob passou a demorar mais de 1 hora para terminar (6.813 terapeutas), e o cron 0 * * * * continuou enfileirando novos a cada hora — 10 instâncias ficaram rodando em paralelo no mesmo dyno.
  2. Como todas as filas compartilhavam o mesmo pool de 5 threads (--queues='*'), os RefreshJobs presos consumiram todas as threads, e jobs leves (Payments::UpdatePixJob, notificações) ficaram na fila sem worker disponível — 1.707 jobs acumularam.
  3. Threads do scheduler ficaram em estado zumbi (active_threads=5, available_threads=0) sem job associado no banco — só o restart do dyno resolveu.

A separação de filas com pool dedicado por queue evita esse cenário: um job travado afeta apenas a própria fila — o resto do sistema (PIX, notificações) segue funcionando.

Além disso, jobs críticos como pagamentos PIX passam a ser processados por dois process types: jobs (2 threads, fallback) e priority (5 threads, dedicado). Se o dyno priority morrer, o jobs ainda processa pagamentos.

O GoodJob já tem proteção nativa contra duplicação de cron quando há múltiplos dynos com --enable-cron: usa UNIQUE constraint no banco para garantir que apenas 1 job seja enfileirado por horário, mesmo com vários processos competindo.

Objetivos

  • Isolar pools de threads por fila para que jobs travados não bloqueiem outras filas
  • Processar a fila payments em dois process types (jobs + priority), garantindo resiliência
  • Garantir que o RefreshJob (pesado) tenha sua própria thread dedicada na fila cron
  • Manter o setup escalável sem coordenação adicional

Fora de escopo

Mudanças

Procfile

Criar o process type priority dedicado à fila payments e configurar pools de threads no jobs:

web: bundle exec puma -C config/puma.rb jobs: bundle exec good_job start --enable-cron --queues='cron:1;payments:2;notifications:2;default:3' priority: bundle exec good_job start --queues='payments:5' release: bundle exec rake db:migrate

Total: 8 threads no jobs (cron + payments + notifications + default), 5 threads no priority e 7 threads totais para payments (2 de fallback + 5 dedicadas).

app/jobs/**/*.rb

Adicionar queue_as em cada job:

Queue Process type Threads Jobs
cron jobs 1 TherapistAvailableTimes::RefreshJob, TherapistAvailableTimes::UpdateLastProfilesChangedJob, ProfessionalPaymentInvoiceCreationJob, ProfessionalPaymentInvoicesDailyProcessingJob, UserClients::InactiveManyJob
payments jobs + priority 2 + 5 = 7 Payments::UpdatePixJob, Payments::ExpireJob, ProfessionalPaymentInvoiceTransferCreationJob
notifications jobs 2 tudo em app/jobs/notifications/**/*.rb, Messenger::SenderNotificationsJob
default jobs 3 SearchTherapists::RefreshByProfileJob, Subscriptions::Pro::AutoSubscribeJob, ActiveStorage::AnalyzeJob, ActiveStorage::PurgeJob, demais

Justificativa:

  • cron: jobs de cron rodam em horários espaçados — não precisam de paralelismo entre si. 1 thread garante que nenhum trava o pool global.
  • payments: pagamentos PIX são tempo-críticos (cliente esperando confirmação) e de alto volume. Dois process types para resiliência.
  • notifications: 2 threads — volume alto, mas jobs leves.
  • default: 3 threads — fila catch-all para todo job sem categorização específica.

Deploy

Após o merge, executar manualmente em produção e staging:

heroku ps:scale priority=1 -a trgclub-api--production heroku ps:scale priority=1 -a trgclub-api--staging

Trade-offs e limitações

Essa mudança não impede OOM do dyno se um único RefreshJob consumir mais memória do que o dyno tem disponível. O que ela faz é limitar o raio de alcance:

  • Antes: RefreshJob travado → ocupa N threads do pool comum → em N ciclos, todas as 5 threads ficam presas → fila inteira parada.
  • Depois: RefreshJob travado → ocupa só a thread cron no dyno jobs → as outras 7 threads do jobs seguem servindo, e o dyno priority segue 100% funcional processando payments.

Como verificar

  1. Localmente, rodar o good_job e confirmar via console: ruby GoodJob::Process.all.map { |p| p.state["schedulers"] } Deve mostrar dois processos: um com schedulers (cron, payments, notifications, default) e outro com (payments).

  2. Simular travamento da fila cron: ruby class HeavyTestJob < ApplicationJob queue_as :cron def perform; sleep 600; end end HeavyTestJob.perform_later Confirmar que jobs em payments/notifications/default continuam sendo processados normalmente em paralelo.

  3. Após o deploy em produção, monitorar o gráfico de memória do dyno jobs e a fila do GoodJob. Esperado: mesmo se o RefreshJob travar, o UpdatePixJob continua processando em paralelo nos dois dynos.

Documentação

  • Criar learning sobre a investigação do memory leak — causa raiz (jobs paralelos travando o pool compartilhado de threads) e por que separar filas + isolar process type para pagamentos resolve o raio de alcance, não o OOM em si.