Contexto
Na iugu, temos um processo rigoroso de cadastro e KYC (Know Your Customer) para todos os nossos clientes. Este processo é fundamental para a conformidade e segurança, e envolve a verificação de diversos documentos. É justamente nesse contexto que notamos um envio excessivo de comunicações, identificado por dois fatores principais: nosso serviço de e-mail teve o número de envios mensais extrapolado muito antes do esperado, e nossos clientes estavam relatando o recebimento de lembretes repetidos.
Dados os dois fatores, o mais lógico seria analisar todos os processos responsáveis pelo envio de e-mails do nosso monolito, e isso nos levou a um worker no sistema que verificava todas as contas dos clientes com documentos pendentes.
Separarei os tópicos do artigo pelos problemas identificados no worker:
- Query ineficiente na busca de contas com documentos pendentes.
- Falta de granularidade nos processos para cada conta encontrada.
- Ausência de observabilidade.
É importante ressaltar que o Claude Code (no meu caso, é o que utilizo) poderia varrer o sistema para encontrar todos esses problemas identificados, inclusive em mais de um worker, mas é crucial trabalhar em conjunto com a inteligência artificial. Acredito que, apesar da assistência da IA, quem detém o contexto, a lógica e as decisões de design importantes é o programador. Então, não deixe de acompanhar de perto as decisões tomadas pelo seu agente, questioná-las e ir além.
Query ineficiente na busca de contas com documentos pendentes.
A query original:
customers = Customer.with_verification_request
.includes(:customer_attributes)
.where('customer_attributes.key = "verification_due_date"')
.where('customer_attributes.value < ?', 10.days.from_now)
.references(:customer_attributes)
Ao otimizar a query, já conseguiríamos um resultado mais performático. Para aqueles que não estão acostumados com Rails, podemos "traduzir" a sintaxe do Rails para SQL puro com o método built-in do Rails chamado .to_sql:
SELECT customers.*
FROM customers
JOIN customer_attributes ON customer_attributes.customer_id = customers.id
WHERE customer_attributes.key = 'verification_due_date'
AND customer_attributes.value < CURRENT_DATE + INTERVAL '10 days';
Identificação dos problemas:
-
includescarrega todos os dados decustomer_attributesna memória - Falta de filtros essenciais na query (status, customers não verificados)
Esses são os problemas essencialmente ligados à query. No entanto, em seguida, temos o bloco de código que traz redundância e lentidão devido à forma como a query foi construída.
customers.find_each do |customer|
next unless customer.verified?
# processamento
end
-
find_eachcarrega objetos Customer completos (todas as colunas) - Filtros aplicados em Ruby após carregar dados (
next unless customer.verified?)
Vamos destrinchar as soluções em forma de resposta aos problemas listados. Lembrando que estas são apenas algumas das possíveis abordagens dentro da situação.
Solução para o carregamento excessivo de dados com includes:
Podemos trocar o uso de includes por joins porque:
- Não carrega associações desnecessárias.
- Apenas filtra os registros.
Isso reduz o uso de memória significativamente.
Solução para a falta de filtros essenciais na query:
Podemos mover todas as condições do código para filtros na nossa query:
- verified: true - antes era customer.verified? em Ruby.
- status: [...] - para garantir apenas clientes pré-aprovados.
E, ao tentar utilizar os índices existentes no banco para as tabelas customers e customer_attributes.
Agora temos uma query um pouco maior em linhas de código, porém mais eficiente (traz apenas as contas que deveríamos atuar).
Resultado:
customer_ids = Customer.with_verification_request
.joins(:customer_attributes)
.where('customer_attributes.key = "verification_due_date"')
.where('customer_attributes.value < ?', 10.days.from_now)
.where(verified: true)
.where(status: ['approved', 'active', 'processing'])
.where('NOT EXISTS(
SELECT 1 FROM customer_attributes ca
WHERE ca.customer_id = customers.id
AND ca.key = "disabled_subaccount"
AND customers.parent_id IS NOT NULL
)')
.pluck('customers.id')
Essa é a query final, com algumas observações:
- Estamos buscando e selecionando apenas os IDs dos clientes com pluck('customers.id'). Agora, a variável que vamos iterar é apenas um array de IDs: customer_ids.
- Na iugu, um cliente pode ter subcontas associadas a ele. Por isso, temos uma subquery para excluir clientes com subcontas desabilitadas.
Comparando o tamanho dos dois arrays que deveríamos iterar:
Query velha (com customers sendo carregados em memória):

Na data de escrita deste artigo, temos um array aproximadamente 7,5 vezes menor que o antigo, e cada um desses itens agora é um dado muito mais simples: um ID.
Falta de granularidade nos processos para cada conta encontrada
O problema aqui era que, se uma exceção ocorresse durante o processamento de uma conta, toda a task falhava e era reiniciada pelo Sidekiq. Na reinicialização, a query era executada novamente, retornando as mesmas contas, incluindo aquela que havia falhado, causando um loop de reinicializações. Enquanto isso, clientes que já haviam sido processados com sucesso recebiam e-mails repetidos.
A ideia aqui foi bem simples: quebrar o processamento em unidades menores e independentes.
Ao invés de processar tudo em um único loop, decidimos adicionar um worker para performar a operação necessária para cada uma das contas dentro do array de IDs. Essa abordagem traz vantagens como:
Isolamento de falhas: Se o processamento de uma conta específica falhar, apenas o worker responsável por ela será afetado, e não a task inteira. Os demais workers continuarão a processar suas respectivas contas sem interrupção.
Retry independente: O Sidekiq pode re-tentar apenas o worker que falhou, sem a necessidade de reprocessar todo o lote de clientes. Isso economiza recursos e evita o envio repetido de comunicações para clientes já processados.
Fomos disso:
class CustomersPendingVerificationTask
include Sidekiq::Worker
def perform
accounts = # Antiga query onerosa
accounts.find_each do |account|
next unless account.is_verified?
result = Cmd::Account::Documents.new(account).call # Processo onde era possível acontecer uma exceção
unless result[:all_documents_valid] AccountNotificationMailerWorker.perform_async(:pending_documents, account.id) # Disparo do e-mail
end
end
...
end
end
Para isso:
class CustomersPendingVerificationTask
include Sidekiq::Worker
def perform
customer_ids = # Query otimizada...
customer_ids.each do |customer_id|
CustomerPendingVerificationWorker.perform_async(customer_id)
end
end
end
O código do Worker criado agora possui o processamento necessário para verificar os documentos do cliente.
class CustomerPendingVerificationWorker
include Sidekiq::Worker
def perform(customer_id)
customer = Customer.find_by(id: customer_id)
return unless customer.present?
result = Services::Customer::VerificationChecker.new(customer).call
unless result[:verification_complete]
NotificationMailerWorker.perform_async(:pending_verification, customer.id)
end
end
end
E isso por si só, matou o problema dos múltiplos disparos repetidos, e também o gasto desnecessário com comunicações extras que estava fora do radar.
Um bônus adicionado aqui foi monitoria, coloquei TaskLogs para: registrar execução e identificar ínicio e final do processo.
class CustomersPendingVerificationTask
include Sidekiq::Worker
def perform
start_task
... # Query e chamada do worker
finish_task
end
private
def start_task
TaskLog.start_batch(
task_name: 'CustomersPendingVerificationTask',
batch_id: nil,
target_type: "Customer"
)
end
def finish_task
TaskLog.finish_batch(
task_name: 'CustomersPendingVerificationTask',
batch_id: nil,
target_type: "Customer"
)
end
end
Com isso finalizo com a ideia de que, uma pequena refatoração em dois arquivo pode salvar uma boa grana e dor de cabeça com base de clientes.

Top comments (0)