DEV Community

Yury Cavalcante
Yury Cavalcante

Posted on

Reescrevendo Legados #01

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:

  1. Query ineficiente na busca de contas com documentos pendentes.
  2. Falta de granularidade nos processos para cada conta encontrada.
  3. 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)
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

Identificação dos problemas:

  • includes carrega todos os dados de customer_attributes na 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
Enter fullscreen mode Exit fullscreen mode
  • find_each carrega 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')
Enter fullscreen mode Exit fullscreen mode

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):

Query nova (apenas os ids):

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)