InícioDesafiosDesafio 09 › Gabarito

Gabarito · Desafio 09 Nível: Avançado

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 List no start. 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 no finish reflete 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 update por registro multiplica pelas fatias e estoura o limite de DML por transação. Junte numa lista e atualize uma vez.
  • Assumir que o execute sempre roda. Se você inicializou algo lá e leu no finish, 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 execute dispara 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 no execute. Se só interessam as contas que podem mudar de faixa, mova a condição para o WHERE do 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.