Tuesday, 22 June 2021

process a large amount of records in serial chunks using Queueables


/**

 * @description A demonstration recipe for how to process a large amount of

 * records in serial chunks using Queueables. The idea behind this recipe

 * is that Queueables, in production, have no max-queue depth. Meaning that so

 * long as you only enqueue one new queueable, it can keep cycling through until

 * the entire data set is processed. This is useful for instance, when you want

 * to process hundreds of thousands of records.

 *

 * Note: You're not able to re-enqueue within a test context, so the unit test

 * for this code is limited to the same number of records as chunkSize below.

 *

 * Note: This should be refactored to be an abstract class that you can extend

 * named 'Ouroboros'. (Ouroboros = the snake eating it's own tail)

 *

 * @group LDV Recipes

 */

public with sharing class LDVRecipes implements Queueable {

    private final Integer chunkSize = 20;

    private Id offsetId;

    private List<ContentDocumentLink> objectsToProcess;

    @testVisible

    private static Integer chunksExecuted = 0;


    /**

     * @description No param constructor. Use for starting the chain.

     */

    public LDVRecipes() {

        this.objectsToProcess = getRecordsToProcess(this.offsetId);

    }


    /**

     * @description    Constructor accepting an ID to use as an offset. Use

     * this version to *continue* the chain.

     * @param offsetId

     */

    public LDVRecipes(Id offsetId) {

        if (offsetId != null) {

            this.offsetId = offsetId;

        }

        this.objectsToProcess = getRecordsToProcess(this.offsetId);

    }


    /**

     * @description            This method contains the 'what' happens to each

     * chunk of records. Note, that this example doesn't actually do any

     * processing. In a real-life use case you'd iterate over the records stored

     * in this.objectsToProcess.

     * @param queueableContext

     */

    public void execute(System.QueueableContext queueableContext) {

        // Used to demonstrate the method was executed.

        LDVRecipes.chunksExecuted += 1;

        // If you're processing the group of records there's likely a better way

        // to determine the last objects' id, but this will do for demonstrating

        // the idea. We need the last id from objectsToProcess in order to

        // construct the next queueable with an offset.

        Id lastRecordId = objectsToProcess[objectsToProcess.size() - 1].id;


        if (getRecordsToProcess(lastRecordId).size() > 0 && safeToReenqueue()) {

            LDVRecipes newQueueable = new LDVRecipes(lastRecordId);

            System.enqueueJob(newQueueable);

        }

    }


    /**

     * @description    Returns a 'cursor' - a set of records of size X from a

     * given offset. Note: We originally intended to use OFFSET - the SOQL

     * keyword, but discovered the max OFFSET size is 2000. This obviously won't

     * work for large data volumes greater than 2000 so we switched to using the

     * ID of the record. Since ID is an indexed field, this should also allow

     * us to prevent full table scans even on the largest tables.

     * @param offsetId The offset id is used to demarcate already processed

     * records.

     * @return         `List<ContentDocumentLink>`

     */

    private List<ContentDocumentLink> getRecordsToProcess(Id offsetId) {

        String queryString = '';

        queryString += 'SELECT ContentDocumentId,ContentDocument.Title, ContentDocument.CreatedDate,LinkedEntityId ';

        queryString += 'FROM ContentDocumentLink ';

        queryString += 'WHERE LinkedEntityId in (SELECT Id FROM Account) ';

        if (offsetId != null) {

            queryString += 'AND ID > :offsetId ';

        }

        queryString += 'WITH SECURITY_ENFORCED ';

        queryString += 'ORDER BY ID ASC ';

        queryString += 'LIMIT :chunkSize';

        return Database.query(queryString);

    }


    private Boolean safeToReenqueue() {

        return Limits.getLimitQueueableJobs() > Limits.getQueueableJobs();

    }

}

No comments:

Post a Comment