Sub-jobs por terapeuta no RefreshJob

TLDR: Refatora o TherapistAvailableTimes::RefreshJob para enfileirar sub-jobs por terapeuta (via EnqueueRefreshByProfile) 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 RemoveUselessTimes rodando 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 + RemoveUselessTimesJob no final
  • spec/jobs/therapist_available_times/remove_useless_times_job_spec.rb (novo) — cobertura do novo job
  • spec/jobs/search_therapists/refresh_by_profile_job_spec.rb — queue_name agora é "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 via EnqueueRefreshByProfile, o segundo cron não duplica.
  • RemoveUselessTimesJob roda em paralelo com os RefreshByProfileJob ainda em processamento. Como o RemoveUselessTimes só 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

  1. Rodar localmente TherapistAvailableTimes::RefreshJob.perform_now e confirmar:
    • N sub-jobs SearchTherapists::RefreshByProfileJob enfileirados (1 por terapeuta com search_therapist)
    • 1 TherapistAvailableTimes::RemoveUselessTimesJob enfileirado
    • Nenhum TherapistAvailableTime alterado durante a execução do RefreshJob (só os sub-jobs alteram)
  2. Em staging, monitorar:
    • Memória do dyno jobs durante e após a execução do cron — deve permanecer estável
    • Tabela good_jobs — após o cron, ~N jobs pendentes em queue_name=bulk
    • Após o próximo cron, dedup ativa — sub-jobs duplicados não são enfileirados
  3. Em produção (pós-deploy):
    • Memória do dyno jobs não passa de ~1 GB
    • TherapistAvailableTime continua sendo atualizado como antes (conferir timestamps)
    • Logs do GoodJob mostram sub-jobs processando em paralelo (2 threads) na fila bulk

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 EnqueueRefreshByProfile com dedupe.