Sub-jobs por terapeuta no RefreshJob
TLDR: Refatora o
TherapistAvailableTimes::RefreshJobpara enfileirar sub-jobs por terapeuta (viaEnqueueRefreshByProfile) em vez de processar todos em memória num único job, eliminando o crescimento descontrolado de memória que causou o OOM de 10 de junho.
Contexto
O TherapistAvailableTimes::RefreshJob (cron a cada hora) fazia:
ruby
def perform(*args)
UserProfile.joins(:search_therapist).find_each do |profile|
SearchTherapists::RefreshByProfile.call(profile.user_id) # síncrono
end
TherapistAvailableTimes::RemoveUselessTimes.call
end
Com 6.813 terapeutas em produção:
- O job processava todos em sequência num único processo.
- Cada terapeuta alocava arrays de ~2 mil entradas (90 dias × horários × availabilities) em memória.
- O GC do Ruby não devolve memória ao SO entre iterações.
- Resultado: o job demorava mais de 1 hora, consumia mais de 2 GB de heap e às vezes era morto por OOM antes de terminar.
- Quando interrompido, o GoodJob retentava automaticamente (
GoodJob::InterruptError). - O cron continuava disparando a cada hora, acumulando RefreshJobs em paralelo (até 10 simultâneos em 10 de junho).
A solução é enfileirar um sub-job por terapeuta (SearchTherapists::RefreshByProfileJob). Cada sub-job processa 1 terapeuta e libera memória ao terminar, é independente (pode ser retentado isoladamente) e roda em paralelo numa nova fila bulk (2 threads).
Usar SearchTherapists::EnqueueRefreshByProfile em vez de perform_later direto garante deduplicação — se o cron disparar novamente antes do lote anterior terminar, não enfileira jobs duplicados para os mesmos terapeutas.
Continuação de goodjob_queue_isolation_and_cron_dyno.
Objetivos
- Eliminar o crescimento de memória do
RefreshJob - Permitir que cada terapeuta seja reprocessado isoladamente em caso de erro
- Evitar acúmulo de jobs duplicados quando o cron dispara com o lote anterior ainda em andamento
- Manter o
RemoveUselessTimesrodando após o refresh, isolado em job próprio
Fora de escopo
- Refatoração de namespace (
Crons::*,Bulks::*com inline class style) — fica para um PR separado
Mudanças
Procfile
Adicionar a fila bulk com 2 threads no process type jobs, para rodar os sub-jobs disparados em fanout pelo cron:
jobs: bundle exec good_job start --enable-cron --queues='cron:1;bulk:2;payments:2;notifications:2;default:3'
priority: bundle exec good_job start --queues='payments:5'
Total: 10 threads em jobs + 5 em priority = 15 threads em 2 process types.
app/jobs/therapist_available_times/refresh_job.rb
Substituir o loop síncrono por enfileiramento de sub-jobs:
```ruby module TherapistAvailableTimes class RefreshJob < ApplicationJob queue_as :cron
def perform(*args)
UserProfile.joins(:search_therapist).find_each do |profile|
SearchTherapists::EnqueueRefreshByProfile.call(profile.user_id)
end
TherapistAvailableTimes::RemoveUselessTimesJob.perform_later
end end end ```
O RefreshJob passa a apenas iterar os profiles (find_each é leve, carrega 1000 por vez), enfileirar sub-jobs com dedupe — terminando em segundos — e enfileirar o RemoveUselessTimesJob no final.
app/jobs/search_therapists/refresh_by_profile_job.rb
Trocar a fila para bulk:
```ruby module SearchTherapists class RefreshByProfileJob < ApplicationJob queue_as :bulk
def perform(...)
SearchTherapists::RefreshByProfile.call(...)
end end end ```
app/jobs/therapist_available_times/remove_useless_times_job.rb (novo)
```ruby module TherapistAvailableTimes class RemoveUselessTimesJob < ApplicationJob queue_as :cron
def perform
TherapistAvailableTimes::RemoveUselessTimes.call
end end end ```
Specs
spec/jobs/therapist_available_times/refresh_job_spec.rb— testa o enfileiramento de sub-jobs +RemoveUselessTimesJobno finalspec/jobs/therapist_available_times/remove_useless_times_job_spec.rb(novo) — cobertura do novo jobspec/jobs/search_therapists/refresh_by_profile_job_spec.rb—queue_nameagora é"bulk"
Trade-offs e limitações
- Throughput total: o lote completo pode demorar mais (sub-jobs serializam em 2 threads) que o job único de hoje. Mas a memória fica estável e o sistema não trava.
- Carga no GoodJob: enfileirar 6.813 jobs/hora gera carga adicional no banco (INSERTs em
good_jobs). Com dedupe viaEnqueueRefreshByProfile, o segundo cron não duplica. RemoveUselessTimesJobroda em paralelo com osRefreshByProfileJobainda em processamento. Como oRemoveUselessTimessó apaga registros antigos (filtro por data), não há condição de corrida com os upserts dos sub-jobs (que escrevem datas futuras).
Como verificar
- Rodar localmente
TherapistAvailableTimes::RefreshJob.perform_nowe confirmar:- N sub-jobs
SearchTherapists::RefreshByProfileJobenfileirados (1 por terapeuta comsearch_therapist) - 1
TherapistAvailableTimes::RemoveUselessTimesJobenfileirado - Nenhum
TherapistAvailableTimealterado durante a execução do RefreshJob (só os sub-jobs alteram)
- N sub-jobs
- Em staging, monitorar:
- Memória do dyno
jobsdurante e após a execução do cron — deve permanecer estável - Tabela
good_jobs— após o cron, ~N jobs pendentes emqueue_name=bulk - Após o próximo cron, dedup ativa — sub-jobs duplicados não são enfileirados
- Memória do dyno
- Em produção (pós-deploy):
- Memória do dyno
jobsnão passa de ~1 GB TherapistAvailableTimecontinua sendo atualizado como antes (conferir timestamps)- Logs do GoodJob mostram sub-jobs processando em paralelo (2 threads) na fila
bulk
- Memória do dyno
Documentação
- Atualizar (ou criar) o learning sobre o memory leak — registrar que a solução final foi quebrar o RefreshJob em sub-jobs por terapeuta, e o padrão de usar
EnqueueRefreshByProfilecom dedupe.