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
priorityno Heroku especializado na filapayments— pagamentos rodam no dynojobs(fallback) e nopriority(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:
TherapistAvailableTimes::RefreshJobpassou a demorar mais de 1 hora para terminar (6.813 terapeutas), e o cron0 * * * *continuou enfileirando novos a cada hora — 10 instâncias ficaram rodando em paralelo no mesmo dyno.- 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. - 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
paymentsem dois process types (jobs+priority), garantindo resiliência - Garantir que o
RefreshJob(pesado) tenha sua própria thread dedicada na filacron - Manter o setup escalável sem coordenação adicional
Fora de escopo
- Resolver o OOM do
RefreshJobem si — o job cresce com o volume de terapeutas e exige refatoração própria (endereçada em refresh_job_subjobs_per_therapist)
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
cronno dynojobs→ as outras 7 threads dojobsseguem servindo, e o dynoprioritysegue 100% funcional processando payments.
Como verificar
-
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). -
Simular travamento da fila
cron:ruby class HeavyTestJob < ApplicationJob queue_as :cron def perform; sleep 600; end end HeavyTestJob.perform_laterConfirmar que jobs empayments/notifications/defaultcontinuam sendo processados normalmente em paralelo. -
Após o deploy em produção, monitorar o gráfico de memória do dyno
jobse a fila do GoodJob. Esperado: mesmo se oRefreshJobtravar, oUpdatePixJobcontinua 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.