Skip to content

feat: add bulk chaining APIs for queueable jobs#22

Open
Oblitus wants to merge 1 commit intobeyond-the-cloud-dev:mainfrom
Oblitus:feature/bulk-chain-builder-api
Open

feat: add bulk chaining APIs for queueable jobs#22
Oblitus wants to merge 1 commit intobeyond-the-cloud-dev:mainfrom
Oblitus:feature/bulk-chain-builder-api

Conversation

@Oblitus
Copy link

@Oblitus Oblitus commented Feb 20, 2026

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().

Includes 9 new test methods covering happy paths, edge cases, and mixed usage.

Description

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Code refactoring
  • Test improvements
  • CI/CD improvements

Changes Made

Motivation

When enqueuing many queueable jobs in a loop, the current API requires manual chain bookkeeping:

List<QueueableJob> jobs = getJobs(); // e.g. 500 jobs

for (Integer i = 0; i < jobs.size() - 1; i++) {
    Async.queueable(jobs[i]).chain();
}
Async.queueable(jobs[jobs.size() - 1]).enqueue();

This is verbose, error-prone (off-by-one on the last job, forgetting to call enqueue() separately), and doesn't feel like the rest of the fluent API.

Why this matters for high job counts

The motivation section above focuses on code ergonomics, but there's a more critical reason: the current loop pattern hits governor limits.

When you enqueue jobs individually in a loop:

for (Integer i = 0; i < 500; i++) {
    Async.queueable(new MyQueueableJob()).enqueue();
}

Each .enqueue() call creates a separate chain. After the 50th System.enqueueJob() (Salesforce limit per synchronous transaction), every subsequent call overflows into QueueableChain.executeOrReplaceInitialQueueableChainSchedulableJob(), which calls System.abortJob() + System.schedule()2 DML statements per overflow job:

Jobs What happens DML per job Total DML
1–50 System.enqueueJob() — each creates its own chain 1 50
51 System.schedule() (first overflow, no abort) 1 1
52–100 System.abortJob() + System.schedule() 2 98
Total 149

At 101 jobs you hit System.LimitException: Too many DML statements: 151.

How the new APIs solve this

Async.queueable(list).enqueue() and Async.chainBuilder()...enqueue() put all jobs into a single chain with one System.enqueueJob() call. The overflow mechanism fires at most once (1 System.schedule()), so DML stays at ~2 total regardless of job count — making 500+ jobs safe.

// ✅ 500 jobs, 1 chain, ~2 DML total
List<QueueableJob> jobs = new List<QueueableJob>();
for (Integer i = 0; i < 500; i++) {
    jobs.add(new MyQueueableJob());
}
Async.queueable(jobs).enqueue();

What this PR adds

Two new entry points that stay consistent with the existing Async.* design language:

1. Async.queueable(List<QueueableJob>) — when you already have a list

List<QueueableJob> jobs = new List<QueueableJob>{
    new SendEmailJob(),
    new UpdateRecordsJob(),
    new NotifyExternalServiceJob()
};

Async.queueable(jobs).enqueue();

All jobs are automatically chained in order. The last job becomes the active builder target, so you can still apply configuration:

Async.queueable(jobs)
    .delay(1)
    .enqueue();

Validates input — null or empty list throws IllegalArgumentException.

2. Async.chainBuilder() — when you're composing incrementally

Async.chainBuilder()
    .add(new SendEmailJob())
    .add(new UpdateRecordsJob())
    .add(new NotifyExternalServiceJob())
    .enqueue();

Supports adding jobs one at a time or in batches:

List<QueueableJob> batchJobs = getBatchJobs();

Async.chainBuilder()
    .add(new SetupJob())
    .add(batchJobs)             // add a whole list at once
    .add(new CleanupJob())
    .enqueue();

Also supports .chain() instead of .enqueue() for nesting inside an outer chain.

Changes

File Change
Async.cls Added queueable(List<QueueableJob>) and chainBuilder() factory methods
QueueableBuilder.cls Added QueueableBuilder(List<QueueableJob>) constructor — chains N-1 jobs, sets last as active
QueueableChainBuilder.cls New — fluent builder with add(job), add(list), enqueue(), chain()
AsyncTest.cls 9 new test methods covering happy paths, edge cases, and mixed usage

Test coverage

  • shouldEnqueueListOfQueueablesSuccessfully — 5 jobs via list
  • shouldEnqueueListOfQueueablesWithDelaySuccessfully — list + .delay(1)
  • shouldFailOnEmptyListOfQueueables — empty list → exception
  • shouldFailOnNullListOfQueueables — null → exception
  • shouldEnqueueChainBuilderSuccessfully — 3 jobs via chainBuilder
  • shouldChainWithChainBuilderWithoutEnqueue.chain() instead of .enqueue()
  • shouldEnqueueChainBuilderWithTwoJobs — verifies chain state job count
  • shouldEnqueueChainBuilderWithListOfJobsadd(list) with 4 jobs
  • shouldEnqueueChainBuilderWithListAndSingleJobs — mixed add(single) + add(list) = 5 jobs

Related Issues

Fixes #
Closes #

Testing

  • All existing tests pass (npm test)
  • Added new tests for new functionality
  • Tested in scratch org
  • Linting passes (npm run lint)
  • Code formatting is correct (npm run prettier:verify)

Screenshots

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code where necessary
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings or errors
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published

Additional Notes

Add Async.queueable(List<QueueableJob>) 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.
@vercel
Copy link

vercel bot commented Feb 20, 2026

Someone is attempting to deploy a commit to the Beyond The Cloud Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant