From 3854d4477ac213947fdb98e4f36aff0e1b802d52 Mon Sep 17 00:00:00 2001 From: rene-knierim Date: Fri, 20 Feb 2026 10:47:36 +0100 Subject: [PATCH] feat: add bulk chaining APIs for queueable jobs Add Async.queueable(List) factory method and QueueableBuilder List constructor to enqueue multiple jobs as a single chain in one call. Add QueueableChainBuilder class with fluent add()/enqueue()/chain() API for incremental chain composition via Async.chainBuilder(). Make ERROR_MESSAGE_NO_JOB_SET public for cross-class access. Includes 9 new test methods covering happy paths, edge cases, and mixed usage. --- force-app/main/default/classes/Async.cls | 8 + force-app/main/default/classes/AsyncTest.cls | 199 +++++++++++++++++- .../classes/queue/QueueableBuilder.cls | 10 + .../classes/queue/QueueableChainBuilder.cls | 20 ++ .../queue/QueueableChainBuilder.cls-meta.xml | 5 + .../classes/queue/QueueableManager.cls | 2 +- website/api/queueable.md | 148 ++++++++++--- 7 files changed, 356 insertions(+), 36 deletions(-) create mode 100644 force-app/main/default/classes/queue/QueueableChainBuilder.cls create mode 100644 force-app/main/default/classes/queue/QueueableChainBuilder.cls-meta.xml diff --git a/force-app/main/default/classes/Async.cls b/force-app/main/default/classes/Async.cls index 4b65ddc..b7929bd 100644 --- a/force-app/main/default/classes/Async.cls +++ b/force-app/main/default/classes/Async.cls @@ -3,6 +3,14 @@ public inherited sharing class Async { return new QueueableBuilder(job); } + public static QueueableBuilder queueable(List jobs) { + return new QueueableBuilder(jobs); + } + + public static QueueableChainBuilder chainBuilder() { + return new QueueableChainBuilder(); + } + public static BatchableBuilder batchable(Object job) { return new BatchableBuilder(job); } diff --git a/force-app/main/default/classes/AsyncTest.cls b/force-app/main/default/classes/AsyncTest.cls index 22a1b9e..f0b88fc 100644 --- a/force-app/main/default/classes/AsyncTest.cls +++ b/force-app/main/default/classes/AsyncTest.cls @@ -2,8 +2,11 @@ * PMD False Positives: * - ApexAssertionsShouldIncludeMessage: IMO not all assertions need a message * - EmptyStatementBlock: It is test class, some methods are just to create jobs placeholders + * - CognitiveComplexity: Test class with many test methods **/ -@SuppressWarnings('PMD.ApexAssertionsShouldIncludeMessage,PMD.EmptyStatementBlock') +@SuppressWarnings( + 'PMD.ApexAssertionsShouldIncludeMessage,PMD.EmptyStatementBlock,PMD.CognitiveComplexity' +) @IsTest @TestVisible private class AsyncTest implements Database.Batchable { @@ -1773,4 +1776,198 @@ private class AsyncTest implements Database.Batchable { } } + // -- Bulk chaining API tests -- + + @IsTest + private static void shouldEnqueueListOfQueueablesSuccessfully() { + List jobs = new List(); + for (Integer i = 0; i < 5; i++) { + jobs.add(new SuccessfulQueueableTest()); + } + + Assert.areEqual(0, [SELECT COUNT() FROM Account]); + + Test.startTest(); + Async.Result ar = Async.queueable(jobs).enqueue(); + Test.stopTest(); + + Assert.areEqual( + QueueableManager.EnqueueType.NEW_CHAIN, + ar.queueableChainState.enqueueType, + 'Should be enqueued as a new chain.' + ); + Assert.areEqual(5, [SELECT COUNT() FROM Account], 'All 5 jobs should have executed.'); + } + + @IsTest + private static void shouldEnqueueListOfQueueablesWithDelaySuccessfully() { + List jobs = new List(); + for (Integer i = 0; i < 3; i++) { + jobs.add(new SuccessfulQueueableTest()); + } + + Assert.areEqual(0, [SELECT COUNT() FROM Account]); + + Test.startTest(); + Async.Result ar = Async.queueable(jobs).delay(1).enqueue(); + Test.stopTest(); + + Assert.areEqual( + QueueableManager.EnqueueType.NEW_CHAIN, + ar.queueableChainState.enqueueType, + 'Should be enqueued as a new chain.' + ); + Assert.areEqual(3, [SELECT COUNT() FROM Account], 'All 3 jobs should have executed.'); + } + + @IsTest + private static void shouldFailOnEmptyListOfQueueables() { + List jobs = new List(); + + Test.startTest(); + try { + Async.queueable(jobs).enqueue(); + Assert.fail('Should have thrown an exception for empty list.'); + } catch (Async.IllegalArgumentException e) { + Assert.areEqual( + QueueableManager.ERROR_MESSAGE_NO_JOB_SET, + e.getMessage(), + 'Should throw no job set error.' + ); + } + Test.stopTest(); + } + + @IsTest + private static void shouldFailOnNullListOfQueueables() { + List jobs = null; + + Test.startTest(); + try { + Async.queueable(jobs).enqueue(); + Assert.fail('Should have thrown an exception for null list.'); + } catch (Async.IllegalArgumentException e) { + Assert.areEqual( + QueueableManager.ERROR_MESSAGE_NO_JOB_SET, + e.getMessage(), + 'Should throw no job set error.' + ); + } + Test.stopTest(); + } + + @IsTest + private static void shouldEnqueueChainBuilderSuccessfully() { + Assert.areEqual(0, [SELECT COUNT() FROM Account]); + + Test.startTest(); + Async.Result ar = Async.chainBuilder() + .add(new SuccessfulQueueableTest()) + .add(new SuccessfulQueueableTest()) + .add(new SuccessfulQueueableTest()) + .enqueue(); + Test.stopTest(); + + Assert.areEqual( + QueueableManager.EnqueueType.NEW_CHAIN, + ar.queueableChainState.enqueueType, + 'Should be enqueued as a new chain.' + ); + Assert.areEqual( + 3, + [SELECT COUNT() FROM Account], + 'All 3 jobs from chainBuilder should have executed.' + ); + } + + @IsTest + private static void shouldChainWithChainBuilderWithoutEnqueue() { + Assert.areEqual(0, [SELECT COUNT() FROM Account]); + + Test.startTest(); + Async.Result ar = Async.chainBuilder() + .add(new SuccessfulQueueableTest()) + .add(new SuccessfulQueueableTest()) + .chain(); + + // chain() only adds to the chain; enqueue triggers execution + Async.queueable(new SuccessfulQueueableTest()).enqueue(); + Test.stopTest(); + + Assert.areEqual( + 3, + [SELECT COUNT() FROM Account], + 'All 3 jobs should have executed (2 chained + 1 enqueued).' + ); + } + + @IsTest + private static void shouldEnqueueChainBuilderWithTwoJobs() { + Assert.areEqual(0, [SELECT COUNT() FROM Account]); + + Test.startTest(); + Async.Result ar = Async.chainBuilder() + .add(new SuccessfulQueueableTest()) + .add(new SuccessfulQueueableTest()) + .enqueue(); + Test.stopTest(); + + Assert.areEqual(QueueableManager.EnqueueType.NEW_CHAIN, ar.queueableChainState.enqueueType); + Assert.areEqual(2, ar.queueableChainState.jobs.size(), 'Chain state should show 2 jobs.'); + Assert.areEqual(2, [SELECT COUNT() FROM Account], 'Both jobs should have executed.'); + } + + @IsTest + private static void shouldEnqueueChainBuilderWithListOfJobs() { + List jobs = new List(); + for (Integer i = 0; i < 4; i++) { + jobs.add(new SuccessfulQueueableTest()); + } + + Assert.areEqual(0, [SELECT COUNT() FROM Account]); + + Test.startTest(); + Async.Result ar = Async.chainBuilder().add(jobs).enqueue(); + Test.stopTest(); + + Assert.areEqual( + QueueableManager.EnqueueType.NEW_CHAIN, + ar.queueableChainState.enqueueType, + 'Should be enqueued as a new chain.' + ); + Assert.areEqual( + 4, + [SELECT COUNT() FROM Account], + 'All 4 jobs from list should have executed.' + ); + } + + @IsTest + private static void shouldEnqueueChainBuilderWithListAndSingleJobs() { + List jobs = new List(); + for (Integer i = 0; i < 3; i++) { + jobs.add(new SuccessfulQueueableTest()); + } + + Assert.areEqual(0, [SELECT COUNT() FROM Account]); + + Test.startTest(); + Async.Result ar = Async.chainBuilder() + .add(new SuccessfulQueueableTest()) + .add(jobs) + .add(new SuccessfulQueueableTest()) + .enqueue(); + Test.stopTest(); + + Assert.areEqual( + QueueableManager.EnqueueType.NEW_CHAIN, + ar.queueableChainState.enqueueType, + 'Should be enqueued as a new chain.' + ); + Assert.areEqual( + 5, + [SELECT COUNT() FROM Account], + 'All 5 jobs (1 single + 3 list + 1 single) should have executed.' + ); + } } diff --git a/force-app/main/default/classes/queue/QueueableBuilder.cls b/force-app/main/default/classes/queue/QueueableBuilder.cls index 4766b93..afbbb70 100644 --- a/force-app/main/default/classes/queue/QueueableBuilder.cls +++ b/force-app/main/default/classes/queue/QueueableBuilder.cls @@ -5,6 +5,16 @@ public inherited sharing class QueueableBuilder { this.job = job.clone(); } + public QueueableBuilder(List jobs) { + if (jobs == null || jobs.isEmpty()) { + throw new Async.IllegalArgumentException(QueueableManager.ERROR_MESSAGE_NO_JOB_SET); + } + for (Integer i = 0; i < jobs.size() - 1; i++) { + QueueableManager.get().chain(jobs.get(i)); + } + this.job = jobs.get(jobs.size() - 1).clone(); + } + public QueueableBuilder asyncOptions(AsyncOptions asyncOptions) { if (job.delay != null) { throw new IllegalArgumentException( diff --git a/force-app/main/default/classes/queue/QueueableChainBuilder.cls b/force-app/main/default/classes/queue/QueueableChainBuilder.cls new file mode 100644 index 0000000..45202c9 --- /dev/null +++ b/force-app/main/default/classes/queue/QueueableChainBuilder.cls @@ -0,0 +1,20 @@ +public inherited sharing class QueueableChainBuilder { + private List jobs = new List(); + + public QueueableChainBuilder add(QueueableJob job) { + return add(new List{ job }); + } + + public QueueableChainBuilder add(List jobs) { + this.jobs.addAll(jobs); + return this; + } + + public Async.Result enqueue() { + return new QueueableBuilder(jobs).enqueue(); + } + + public Async.Result chain() { + return new QueueableBuilder(jobs).chain(); + } +} diff --git a/force-app/main/default/classes/queue/QueueableChainBuilder.cls-meta.xml b/force-app/main/default/classes/queue/QueueableChainBuilder.cls-meta.xml new file mode 100644 index 0000000..82775b9 --- /dev/null +++ b/force-app/main/default/classes/queue/QueueableChainBuilder.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + diff --git a/force-app/main/default/classes/queue/QueueableManager.cls b/force-app/main/default/classes/queue/QueueableManager.cls index c5f69bf..91c318a 100644 --- a/force-app/main/default/classes/queue/QueueableManager.cls +++ b/force-app/main/default/classes/queue/QueueableManager.cls @@ -3,7 +3,7 @@ public inherited sharing class QueueableManager { public static final String ERROR_MESSAGE_ASYNC_OPTIONS_AFTER_DELAY = 'Cannot set asyncOptions after delay has been set'; public static final String ERROR_MESSAGE_DELAY_AFTER_ASYNC_OPTIONS = 'Cannot set delay after asyncOptions has been set'; private static final String ERROR_MESSAGE_CHAIN_NULL = 'QueueableChain cannot be null'; - private static final String ERROR_MESSAGE_NO_JOB_SET = 'No Queueable job has been set to enqueue'; + public static final String ERROR_MESSAGE_NO_JOB_SET = 'No Queueable job has been set to enqueue'; private static final String ERROR_MESSAGE_FINALIZER_JOB_NOT_SET = 'No Finalizer job has been set to attach finalizer'; @TestVisible private static final String ERROR_MESSAGE_CANNOT_ATTACH_FINALIZER = 'Cannot attach finalizer when not in a QueueableChain context'; diff --git a/website/api/queueable.md b/website/api/queueable.md index 4f65ad7..7c98e79 100644 --- a/website/api/queueable.md +++ b/website/api/queueable.md @@ -1,8 +1,10 @@ # Queueable API -Apex classes `QueueableBuilder.cls`, `QueueableManager.cls`, and `QueueableJob.cls`. +Apex classes `QueueableBuilder.cls`, `QueueableManager.cls`, and +`QueueableJob.cls`. -For testing patterns and best practices, see [Testing Async Jobs](/explanations/testing-async-jobs). +For testing patterns and best practices, see +[Testing Async Jobs](/explanations/testing-async-jobs). **Common Queueable example:** @@ -21,10 +23,10 @@ Returns `result.customJobId` containing MyQueueableJob's unique Custom Job Id. ```apex public class AccountProcessorJob extends QueueableJob { - public override void work() { - // Get job context - Async.QueueableJobContext ctx = Async.getQueueableJobContext(); - } + public override void work() { + // Get job context + Async.QueueableJobContext ctx = Async.getQueueableJobContext(); + } } ``` @@ -32,10 +34,10 @@ public class AccountProcessorJob extends QueueableJob { ```apex private class ProcessorFinalizer extends QueueableJob.Finalizer { - public override void work() { - // Get finalizer context - FinalizerContext finalizerCtx = Async.getQueueableJobContext().finalizerCtx; - } + public override void work() { + // Get finalizer context + FinalizerContext finalizerCtx = Async.getQueueableJobContext().finalizerCtx; + } } ``` @@ -46,6 +48,8 @@ The following are methods for using Async with Queueable jobs: [**INIT**](#init) - [`queueable(QueueableJob job)`](#queueable) +- [`queueable(List jobs)`](#queueable-list) +- [`chainBuilder()`](#chainbuilder) [**Build**](#build) @@ -90,6 +94,81 @@ Async queueable(QueueableJob job); Async.queueable(new MyQueueableJob()); ``` +#### queueable list + +Constructs a new QueueableBuilder instance with a list of queueable jobs. All +jobs except the last are automatically chained in order. The last job becomes +the active builder target, so builder configuration (e.g. `delay()`, +`priority()`) applies to it. Throws `IllegalArgumentException` if the list is +null or empty. + +**Signature** + +```apex +QueueableBuilder queueable(List jobs); +``` + +**Example** + +```apex +List jobs = new List{ + new FirstJob(), + new SecondJob(), + new ThirdJob() +}; + +Async.Result result = Async.queueable(jobs) + .delay(1) + .enqueue(); +``` + +All three jobs execute sequentially as a single chain. Only one +`System.enqueueJob()` call is made, avoiding DML governor limit issues when +enqueuing many jobs. + +#### chainBuilder + +Creates a new QueueableChainBuilder for incrementally composing a chain of jobs. +Jobs are added one at a time or in batches via `add()`, then dispatched with +`enqueue()` or `chain()`. + +**Signature** + +```apex +QueueableChainBuilder chainBuilder(); +``` + +**Example** + +```apex +Async.Result result = Async.chainBuilder() + .add(new FirstJob()) + .add(new SecondJob()) + .add(new ThirdJob()) + .enqueue(); +``` + +**QueueableChainBuilder methods:** + +| Method | Signature | Description | +| --------- | ---------------------------------------------------- | -------------------------------------------------------------------------------- | +| `add` | `QueueableChainBuilder add(QueueableJob job)` | Adds a single job to the chain | +| `add` | `QueueableChainBuilder add(List jobs)` | Adds multiple jobs to the chain | +| `enqueue` | `Async.Result enqueue()` | Enqueues all added jobs as a single chain | +| `chain` | `Async.Result chain()` | Adds all jobs to the chain without enqueuing (for nesting inside an outer chain) | + +**Batch example:** + +```apex +List batchJobs = getBatchJobs(); + +Async.Result result = Async.chainBuilder() + .add(new SetupJob()) + .add(batchJobs) + .add(new CleanupJob()) + .enqueue(); +``` + ### Build #### asyncOptions @@ -253,7 +332,8 @@ Async.Result result = Async.queueable(new MyQueueableJob()) .chain(new MyOtherQueueableJob()); ``` -Returns `result.customJobId` containing MyOtherQueueableJob's unique Custom Job Id. To obtain MyQueueableJob's Id, use `chain()` method separately. +Returns `result.customJobId` containing MyOtherQueueableJob's unique Custom Job +Id. To obtain MyQueueableJob's Id, use `chain()` method separately. #### asSchedulable @@ -322,21 +402,21 @@ Async.Result result = Async.queueable(new MyQueueableJob()) **Result properties:** -| Property | Description | -|----------|-------------| -| `salesforceJobId` | Salesforce Job Id of either Queueable Job or Initial Queueable Chain Schedulable (empty if job was not the enqueued one in chain) | -| `customJobId` | Unique Custom Job Id | -| `asyncType` | `Async.AsyncType.QUEUEABLE` | -| `queueableChainState` | Chain state object (see below) | +| Property | Description | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `salesforceJobId` | Salesforce Job Id of either Queueable Job or Initial Queueable Chain Schedulable (empty if job was not the enqueued one in chain) | +| `customJobId` | Unique Custom Job Id | +| `asyncType` | `Async.AsyncType.QUEUEABLE` | +| `queueableChainState` | Chain state object (see below) | **`queueableChainState` properties:** -| Property | Description | -|----------|-------------| -| `jobs` | All jobs in chain including finalizers and processed jobs | -| `nextSalesforceJobId` | Salesforce Job Id that will run next from chain | -| `nextCustomJobId` | Custom Job Id that will run next from chain | -| `enqueueType` | How the chain was enqueued: `EXISTING_CHAIN`, `NEW_CHAIN`, or `INITIAL_QUEUEABLE_CHAIN_SCHEDULABLE` | +| Property | Description | +| --------------------- | --------------------------------------------------------------------------------------------------- | +| `jobs` | All jobs in chain including finalizers and processed jobs | +| `nextSalesforceJobId` | Salesforce Job Id that will run next from chain | +| `nextCustomJobId` | Custom Job Id that will run next from chain | +| `enqueueType` | How the chain was enqueued: `EXISTING_CHAIN`, `NEW_CHAIN`, or `INITIAL_QUEUEABLE_CHAIN_SCHEDULABLE` | #### attachFinalizer @@ -380,16 +460,16 @@ Async.QueueableJobContext ctx = Async.getQueueableJobContext(); **Context properties:** -| Property | Description | -|----------|-------------| -| `ctx.currentJob` | Current `QueueableJob` instance | -| `ctx.queueableCtx` | Salesforce `QueueableContext` | +| Property | Description | +| ------------------ | ------------------------------------------------------- | +| `ctx.currentJob` | Current `QueueableJob` instance | +| `ctx.queueableCtx` | Salesforce `QueueableContext` | | `ctx.finalizerCtx` | Salesforce `FinalizerContext` (available in finalizers) | #### getQueueableChainSchedulableId -Gets the ID of the initial Queueable Chain Schedulable if the current execution is part of -a scheduled-based chain. +Gets the ID of the initial Queueable Chain Schedulable if the current execution +is part of a scheduled-based chain. **Signature** @@ -423,9 +503,9 @@ QueueableChainState currentChain = Async.getCurrentQueueableChainState(); **Chain state properties:** -| Property | Description | -|----------|-------------| -| `jobs` | All jobs in chain including processed ones and finalizers | +| Property | Description | +| --------------------- | ------------------------------------------------------------------ | +| `jobs` | All jobs in chain including processed ones and finalizers | | `nextSalesforceJobId` | Salesforce Job Id that will run next (empty if chain not enqueued) | -| `nextCustomJobId` | Custom Job Id that will run next from chain | -| `enqueueType` | Empty until set during `enqueue()` method | +| `nextCustomJobId` | Custom Job Id that will run next from chain | +| `enqueueType` | Empty until set during `enqueue()` method |