Início › Desafios › Desafio 09 › Gabarito
Gabarito: processar meio milhão de contas em lote
Esta página destrincha a solução do desafio de Batch Apex. Se você ainda não tentou, vale voltar e passar um tempo com o problema: batch tem pouca sintaxe e muita decisão de projeto, e é justamente nas decisões (QueryLocator, estado entre lotes, idempotência) que o aprendizado acontece. Se já tentou, vamos comparar.
Spoiler à frente. A solução completa está logo abaixo. Voltar para o desafio
O problema em uma frase
Um update síncrono de meio milhão de registros bate em dois tetos de uma vez. A leitura para
uma List estoura o heap, e o DML estoura o limite de 10 mil linhas por transação, com o erro
System.LimitException: Too many DML rows: 10001. Batch Apex existe para quebrar esse trabalho
em lotes assíncronos, cada um com o seu próprio orçamento de limites, sem que você tenha que gerenciar a
paginação na mão.
A ideia que amarra tudo: o volume total do trabalho não pode viver dentro de uma única transação. O batch consulta uma vez, o Salesforce serve os registros em fatias, e cada fatia roda isolada das outras.
A solução completa
public with sharing class ReclassificaContasBatch
implements Database.Batchable<sObject>, Database.Stateful {
// Database.Stateful mantem este valor vivo entre os lotes.
// Sem ele, cada execute nasceria com o contador zerado.
public Integer contasAlteradas = 0;
public Database.QueryLocator start(Database.BatchableContext bc) {
// QueryLocator, nao List: aguenta ate 50 milhoes de linhas.
// Uma List estouraria o heap muito antes de meio milhao.
return Database.getQueryLocator([
SELECT Id, Rating, AnnualRevenue
FROM Account
]);
}
public void execute(Database.BatchableContext bc, List<Account> escopo) {
List<Account> paraAtualizar = new List<Account>();
for (Account conta : escopo) {
String novo = faixaPorReceita(conta.AnnualRevenue);
// So entra na lista quem realmente muda. E isto que da
// idempotencia: reprocessar o mesmo escopo nao acha nada
// para atualizar e nao soma no contador.
if (conta.Rating != novo) {
conta.Rating = novo;
paraAtualizar.add(conta);
}
}
if (!paraAtualizar.isEmpty()) {
update paraAtualizar; // um unico DML por lote
contasAlteradas += paraAtualizar.size();
}
}
public void finish(Database.BatchableContext bc) {
// Roda uma vez, mesmo que o start venha vazio e nenhum
// execute tenha rodado. Bom lugar para logar ou notificar.
System.debug('Contas reclassificadas: ' + contasAlteradas);
}
// Regra de negocio isolada: faixa de Rating por AnnualRevenue.
// Funcao pura, o que a torna facil de testar e idempotente.
private String faixaPorReceita(Decimal receita) {
if (receita == null) return 'Cold';
if (receita >= 10000000) return 'Hot';
if (receita >= 1000000) return 'Warm';
return 'Cold';
}
}
Destrinchando parte por parte
1. A assinatura e o acumulador
public with sharing class ReclassificaContasBatch
implements Database.Batchable<sObject>, Database.Stateful {
public Integer contasAlteradas = 0;
São duas interfaces, e cada uma paga um papel diferente. Database.Batchable<sObject> é o
contrato que obriga os três métodos (start, execute, finish) e faz o
Salesforce aceitar rodar a classe em lotes. O parâmetro genérico <sObject> combina com o
QueryLocator que o start devolve.
Database.Stateful é a que muita gente esquece. Por padrão, o Salesforce descarta o estado da
instância entre um lote e o outro: cada execute recebe uma instância recém-construída, com
contasAlteradas de volta em zero. Implementar Stateful diz à plataforma para
preservar as variáveis de instância do começo ao fim do job. É o que permite somar um contador que
atravessa todos os lotes e ler o total no finish.
2. O start com QueryLocator (a decisão que escala)
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Rating, AnnualRevenue
FROM Account
]);
}
O start pode devolver duas coisas: um Iterable (na prática, uma
List) ou um Database.QueryLocator. A diferença não é estilo, é limite. Um
Iterable montado a partir de uma consulta comum obedece ao teto de 50 mil registros por
transação e materializa tudo na memória de uma vez. Com meio milhão de contas, ele nem chega a começar.
O QueryLocator não traz os registros: ele representa a consulta e deixa o Salesforce servir os
dados em fatias, direto do banco, até um teto de 50 milhões de linhas. É por isso que a regra de aceite
cravava QueryLocator. Repare também que o SELECT traz só os três campos que o
execute usa. Campo que você não lê é heap desperdiçado, e em batch grande isso conta.
3. O execute que só toca no que muda (idempotência)
for (Account conta : escopo) {
String novo = faixaPorReceita(conta.AnnualRevenue);
if (conta.Rating != novo) {
conta.Rating = novo;
paraAtualizar.add(conta);
}
}
if (!paraAtualizar.isEmpty()) {
update paraAtualizar;
contasAlteradas += paraAtualizar.size();
}
Aqui mora a parte que separa quem leu o enunciado de quem entendeu. O caminho ingênuo é setar o
Rating de toda conta do escopo e dar update na lista inteira. Funciona na primeira
passada, mas viola a idempotência: rodar de novo reescreve todos os registros, dispara triggers e fluxos de
novo, e infla o contador com contas que não mudaram nada.
A correção é comparar o valor calculado com o que já está no registro e só incluir no update
quem de fato muda. Numa base já classificada, paraAtualizar fica vazio, o update
nem roda (protegido pelo isEmpty()) e o contador não sobe. Reprocessar vira um não-evento, que
é exatamente o que idempotência significa. Isso também economiza DML de verdade: só paga pelo que precisa
mudar, não pelo tamanho do lote.
O update fica fora do laço, uma vez por lote. DML dentro do for é o erro clássico
de bulkificação, e num batch ele multiplica por cada fatia até estourar as 150 operações de DML por
transação.
4. O finish que roda mesmo com base vazia
public void finish(Database.BatchableContext bc) {
System.debug('Contas reclassificadas: ' + contasAlteradas);
}
O finish roda uma única vez, depois do último lote, e é o único dos três garantido a rodar
quando o start não devolve registro nenhum. Esse é o caso de borda da regra de aceite: base
sem contas, o Salesforce pula o execute por completo, mas ainda chama start e
finish. Como o contador começa em zero e o finish só lê, o batch termina limpo,
sem exceção. Nenhum tratamento especial é preciso, desde que você não tenha assumido em lugar nenhum que o
execute rodou. É o lugar natural para enviar um e-mail de resumo, encadear o próximo batch ou
gravar o total num log.
5. A regra de negócio como função pura
private String faixaPorReceita(Decimal receita) {
if (receita == null) return 'Cold';
if (receita >= 10000000) return 'Hot';
if (receita >= 1000000) return 'Warm';
return 'Cold';
}
Isolar a faixa num método sem efeito colateral tem três ganhos. Ela fica trivial de testar em unidade, sem
precisar montar um batch inteiro. Ela é determinística: a mesma receita devolve sempre o mesmo Rating, e é
essa determinação que sustenta a idempotência do passo 3. E o null vem tratado primeiro,
porque comparação numérica com null em Apex não é o que você espera e a regra pedia que conta
sem faturamento caísse em Cold.
Erros comuns
- Devolver uma
Listnostart. Passa no teste com dez contas e estoura o heap na base real. É o erro que o enunciado ataca de frente. - Esquecer o
Database.Stateful. O contador zera a cada lote e o total nofinishreflete só a última fatia, não o job inteiro. Some silenciosamente e o número sai errado. - Atualizar o escopo inteiro sem comparar. Quebra a idempotência: reprocessar reescreve tudo, redispara automações e conta contas que não mudaram.
- DML dentro do laço. Um
updatepor registro multiplica pelas fatias e estoura o limite de DML por transação. Junte numa lista e atualize uma vez. - Assumir que o
executesempre roda. Se você inicializou algo lá e leu nofinish, a base vazia deixa esse valor nulo e o job quebra no fim. - Testar batch em Execute Anonymous esperando asserção. O job é assíncrono; sem
Test.stopTest()num teste de verdade, você asserta antes de o batch rodar.
Como saber se a sua solução passou
A classe de teste do desafio é a prova objetiva. O primeiro método confirma que cada faixa vira o Rating
certo e que a conta já correta não é tocada. O segundo é o que fecha o desafio: ele chama o
execute na mão sobre uma base já classificada e verifica que contasAlteradas
continua em zero. Se esse número subir, falta o filtro de diferença e a sua solução não é idempotente. Rode
com Run Test e confira que os dois métodos passam verdes.
Um passo além
Para levar a solução ao nível que se espera em produção, três evoluções:
-
Escolher o tamanho de lote conscientemente. O padrão é 200, mas se o
executedispara triggers e fluxos pesados, um lote menor evita estourar CPU dentro da fatia. O raciocínio de quando cada ferramenta assíncrona cabe está em Queueable, Batch e Schedulable: qual usar. -
Filtrar no
start, não noexecute. Se só interessam as contas que podem mudar de faixa, mova a condição para oWHEREdo QueryLocator. Menos registros servidos é menos heap e menos lote, e o batch inteiro fica mais barato. -
Tratar falha parcial e reprocessamento. Um lote pode falhar sem derrubar os outros;
Database.update(lista, false)deixa você capturar a linha ruim em vez de perder a fatia inteira. O panorama de tetos que o batch respeita está em governor limits em Apex.
O que fica deste desafio: processamento em massa não é o mesmo código com uma lista maior. São duas perguntas que você passa a fazer sozinho diante de qualquer operação sobre a base inteira: "isso cabe numa transação?" e "o que acontece se rodar duas vezes?". Quem responde as duas escreve batch de produção; quem responde só a primeira escreve batch que dá plantão no fim de semana.