Início › Desafios › Desafio 09
Atualizar meio milhão de contas sem estourar o limite
Um dia a demanda parece boba: "reclassifica todas as contas de acordo com o faturamento". Você escreve o
laço, roda com as suas dez contas de teste e funciona. Aí você aponta para a base real, com meio milhão de
registros, e a org devolve Too many DML rows: 10001 antes de terminar a primeira fração do
trabalho. Processar volume grande não é o mesmo código com uma lista maior: é outra arquitetura.
O cenário
A diretoria quer que o campo Rating de toda conta reflita a faixa de faturamento anual: contas
grandes viram Hot, médias viram Warm, o resto fica Cold. É uma
classificação que precisa rodar uma vez agora, sobre a base inteira, e depois vira rotina.
A primeira versão que alguém escreve é a óbvia: consulta todas as contas numa lista e faz um
update no fim. Com meio milhão de registros isso morre duas vezes. Primeiro no
SELECT, porque uma List não segura 500 mil linhas na memória e você bate no limite
de heap. Se sobrevivesse, morreria no update, porque uma transação síncrona aceita no máximo
10 mil linhas de DML.
O jeito certo é partir o trabalho em lotes que rodam de forma assíncrona, cada um com o seu próprio conjunto de limites. Sua missão é reescrever essa classificação como um Batch Apex que aguenta qualquer volume, sabe quantas contas mudou no total e pode ser rodado de novo sem estragar o resultado.
O que você deve construir
Crie uma classe Apex chamada ReclassificaContasBatch que implemente as duas
interfaces abaixo, com os três métodos do contrato de batch:
public with sharing class ReclassificaContasBatch
implements Database.Batchable<sObject>, Database.Stateful {
public Database.QueryLocator start(Database.BatchableContext bc)
public void execute(Database.BatchableContext bc, List<Account> escopo)
public void finish(Database.BatchableContext bc)
}
A regra de faixa é esta, sobre o campo padrão AnnualRevenue:
AnnualRevenuemaior ou igual a 10.000.000 devolveHot.- Maior ou igual a 1.000.000 devolve
Warm. - Qualquer outro valor, inclusive em branco, devolve
Cold.
Regras de aceite
São elas que definem se o desafio foi resolvido. Três delas são o coração do exercício, não passe por cima:
- O
startdevolve umDatabase.QueryLocator, não umaList. É o QueryLocator que escala até 50 milhões de registros; uma lista estouraria o heap muito antes do meio milhão. - O
executesó atualiza e só conta as contas cujoRatingcalculado é diferente do atual. Conta que já está na faixa certa não entra noupdate. - Idempotência: rodar o batch de novo sobre uma base já classificada não pode alterar nenhum registro nem somar no contador. Reprocessar um escopo tem que ser um não-evento.
- A classe acumula, entre todos os lotes, o total de contas alteradas, e esse total fica
disponível no fim. É para isso que serve o
Database.Stateful. - Um único
updatepor lote. Nenhuma consulta e nenhum DML dentro de laço noexecute. - Borda: se o
startnão retornar nenhuma conta, o batch termina limpo, sem chamarexecutee sem lançar exceção.
O que este desafio avalia
- Se você conhece a anatomia de um Batch Apex e o que cada um dos três métodos faz.
- Se você sabe por que
QueryLocatorexiste e quando ele é obrigatório. - Se você entende estado entre lotes, e que sem
Database.Statefulcadaexecutenasce zerado. - Se você projeta a operação para ser idempotente, e não só para funcionar na primeira passada.
Travei. Me dá uma direção (sem entregar a resposta)
Quatro perguntas que destravam o desafio:
- Qual interface uma classe precisa implementar para o Salesforce aceitar rodá-la em lotes, e quais são os três métodos que ela obriga? Pesquise por "Database.Batchable interface Apex".
- O
startpode devolver uma coleção ou um objeto que representa a consulta sem materializá-la. Qual dos dois aguenta milhões de linhas? Pesquise por "Database.QueryLocator vs Iterable batch". - Por padrão, cada
executecomeça com as variáveis da instância zeradas. O que você adiciona à classe para um contador sobreviver de um lote para o outro? Pesquise por "Database.Stateful example". - Como fazer o batch não repetir o efeito quando rodar de novo? Pense em comparar o valor que você calculou com o valor que já está no registro antes de decidir atualizar.
Sobre disparar o batch: procure "Database.executeBatch scope size". O segundo argumento controla o tamanho do lote, e o padrão é 200.
Como testar
Batch é assíncrono, então a forma honesta de provar as regras é uma classe de teste com
Test.startTest() e Test.stopTest(), que força o job a terminar antes das asserções.
Crie a classe abaixo e rode com Run Test:
@isTest
private class ReclassificaContasBatchTest {
@TestSetup
static void semear() {
List<Account> contas = new List<Account>{
new Account(Name = 'Grande', AnnualRevenue = 20000000, Rating = 'Cold'), // vira Hot
new Account(Name = 'Media', AnnualRevenue = 2000000, Rating = 'Cold'), // vira Warm
new Account(Name = 'Pequena', AnnualRevenue = 5000, Rating = 'Cold'), // ja Cold
new Account(Name = 'SemReceita', AnnualRevenue = null, Rating = 'Warm') // vira Cold
};
insert contas;
}
@isTest
static void classificaCadaFaixa() {
Test.startTest();
Database.executeBatch(new ReclassificaContasBatch(), 200);
Test.stopTest(); // so aqui o batch roda ate o fim
Map<String, Account> porNome = new Map<String, Account>();
for (Account a : [SELECT Name, Rating FROM Account]) {
porNome.put(a.Name, a);
}
System.assertEquals('Hot', porNome.get('Grande').Rating, 'Receita alta vira Hot');
System.assertEquals('Warm', porNome.get('Media').Rating, 'Receita media vira Warm');
System.assertEquals('Cold', porNome.get('Pequena').Rating, 'Receita baixa fica Cold');
System.assertEquals('Cold', porNome.get('SemReceita').Rating, 'Receita nula vira Cold');
}
@isTest
static void reprocessarNaoAplicaDeNovo() {
Test.startTest();
Database.executeBatch(new ReclassificaContasBatch(), 200);
Test.stopTest(); // base ja classificada
// Segunda passada: chamamos o execute na mao com o escopo ja correto.
// Se nada mudar, o contador da instancia tem de continuar em zero.
ReclassificaContasBatch job = new ReclassificaContasBatch();
List<Account> escopo = [SELECT Id, Rating, AnnualRevenue FROM Account];
job.execute(null, escopo);
System.assertEquals(0, job.contasAlteradas, 'Reprocessar nao pode alterar nada');
}
}
O segundo teste é o que separa a solução que funciona da que está certa: ele prova que reprocessar uma base já classificada é um não-evento. Se o seu contador subir na segunda passada, falta o filtro que compara o valor calculado com o atual antes de atualizar.
Resolveu? O ganho aqui não é decorar os três métodos do batch: é sair com o reflexo de perguntar "e se isso rodar sobre a base inteira, e se rodar duas vezes". Volume e idempotência são as duas perguntas que todo processamento em massa de produção precisa responder.