InícioDesafios › Desafio 09

Desafio 09 Nível: Avançado

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.

Batch Apex Database.Batchable QueryLocator Database.Stateful Idempotência Governor limits

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:

  • AnnualRevenue maior ou igual a 10.000.000 devolve Hot.
  • 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 start devolve um Database.QueryLocator, não uma List. É o QueryLocator que escala até 50 milhões de registros; uma lista estouraria o heap muito antes do meio milhão.
  • O execute só atualiza e só conta as contas cujo Rating calculado é diferente do atual. Conta que já está na faixa certa não entra no update.
  • 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 update por lote. Nenhuma consulta e nenhum DML dentro de laço no execute.
  • Borda: se o start não retornar nenhuma conta, o batch termina limpo, sem chamar execute e 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 QueryLocator existe e quando ele é obrigatório.
  • Se você entende estado entre lotes, e que sem Database.Stateful cada execute nasce 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 start pode 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 execute começ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.

Gabarito comentado

A classe inteira, destrinchada parte por parte: por que QueryLocator e nao List, como o Stateful segura o contador entre lotes, o filtro que garante idempotencia e os erros que mais reprovam. Tente resolver antes de abrir.

Ver o gabarito

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.