Skip to content

Async Lib for AI Agents ​

The whole public surface on one page: entry points, every builder method, what you implement, what you get back, configuration, and the mistakes agents make most. Written to be read once and then copied from. For humans the rest of the docs go deeper; for agents there is also /llms.txt and /llms-full.txt, which the build generates from every page.

Names below are for a source deploy. On a packaged install prefix every class with btcdev. (btcdev.Async, btcdev.QueueableJob) and every object and field with btcdev__. Details in Installing as a Package.

Entry points ​

CallReturnsUse it for
Async.queueable(QueueableJob job)QueueableBuilderone job, or the start of a chain
Async.queueable()QueueableBuilderan empty builder to .chain(job) into; enqueue() starts whatever was chained earlier in the transaction, by anyone, and does nothing if nothing is waiting
Async.chunk(ChunkJob job, ChunkSource source)ChunkBuilderone job over many records, one page per transaction
Async.batchable(Database.Batchable job)BatchableBuildera standard batch you have a reason to keep, with scope and delay; for new work prefer Async.chunk
Async.schedulable(Schedulable job)SchedulableBuildera standard schedulable, with cron helpers
Async.after(Result r) / Async.after(String customJobId) / Async.afterPrevious()Async.Dependencythe target of dependsOn(...), finished with .succeeded(), .failed() or .finished()
Async.stopChain()inside a job or finalizer: skip every remaining job in the chain
Async.skipJob(String customJobId)inside a job or finalizer: skip one job and its finalizers
Async.requeue(Id resultId) / Async.requeue(Set<Id> resultIds)Async.RequeueSummaryreplay failed jobs from their AsyncResult__c records
Async.requeueBatchFailures(Object batch, Id batchJobId, List<SObjectField> fieldsToReload) / (batch, batchJobId, List<String> fieldsToReload) / (batch, batchJobId, Async.BatchRecordLoader loader)Async.RequeueSummaryrun the failed scopes of a batch again through its own execute(); the third argument is required and says how to reload the records
Async.getQueueableJobContext()Async.QueueableJobContextinside a job: the current job, QueueableContext, FinalizerContext
Async.getCurrentQueueableChainState()Async.QueueableChainStateevery job in the chain and what runs next
Async.getQueueableChainSchedulableId()Idthe scheduled job id when the chain started through the 50-job overflow path
Async.Backoff.fixed(m) / .exponential(m) / .exponentialWithJitter(m)Backoffthe same three as Backoff.*, safe to call inside a QueueableJob subclass

Builders ​

Every builder is fluent. enqueue() starts a chain, chain() adds to it without starting, enqueue() on the last builder starts everything chained before it. Jobs chained and not yet enqueued wait in one open chain shared by all code in the transaction, and every enqueue() in a normal transaction starts a separate chain: Chaining Across Services.

QueueableBuilder ​

MethodWhat it does
priority(Integer)lower runs first
delay(Integer minutes)0 to 10, the platform cap; cannot combine with asyncOptions
asyncOptions(AsyncOptions)duplicate-signature control; cannot combine with delay
continueOnJobExecuteFail()swallow the exception, commit partial DML, chain continues
rollbackOnJobExecuteFail()roll back this job's DML on failure, chain continues
continueOnJobEnqueueFail()chain continues if this job cannot be enqueued
retry(Integer maxRetries)0 to 10 more attempts after the first; needs Async.Retryable or restoreStateOnRetry()
backoff(Backoff)delay between attempts, minutes, clamped to 10
retryOn(Type) / retryOn(List<Type>)only these exception types retry; ANDed with isRetryable()
restoreStateOnRetry()replay every attempt from the job as it was at enqueue
deepClone()copy collections and objects, not just references, when cloning the job
dependsOn(Async.Dependency)skip this job unless the target had that outcome
info(String key, String value) / info(Map<String, String>)metadata that arrives on every lifecycle context
mockId(String)key for AsyncMock in tests
chain(QueueableJob next)add this job to the chain and hold next
chunk(ChunkJob, ChunkSource)add this job, then continue as a ChunkBuilder
asSchedulable()continue as a SchedulableBuilder
chain()add to the chain, do not start it; returns Async.Result
attachFinalizer()inside work(): run this job after the current one, success or failure
enqueue()start the chain; returns Async.Result

ChunkBuilder ​

Everything from QueueableBuilder that makes sense for a run, plus:

MethodWhat it does
chunkSize(Integer)records per page, default 200, capped by the source
delayBetweenChunks(Integer minutes)wait between pages
stopRemainingChunksOnFailure()a failed page ends the run; default is to continue
keepChunkPages()keep every page's job in the chain state instead of dropping recorded ones
restoreStateOnNextChunk()replay every page from the job as it was at enqueue
chain(QueueableJob next) / chunk(ChunkJob, ChunkSource)continue the chain after the run
chain() / enqueue()as above

ChunkSource ​

FactoryReads from
ChunkSource.of(List<SObject>)records already in memory
ChunkSource.ofIds(Set<Id>)id-only records in memory; query the fields you need inside work()
ChunkSource.query(String soql)a Database.Cursor over the query, system mode
ChunkSource.query(soql, AccessLevel) / query(soql, Map<String, Object> binds) / query(soql, binds, AccessLevel)the same with user mode or bind variables
ChunkSource.cursor(Database.Cursor)a cursor you opened yourself

Your own: extend ChunkSource, implement getNumRecords() and fetch(Integer position, Integer count).

BatchableBuilder ​

MethodWhat it does
scopeSize(Integer)records per execute
execute()run now; returns Async.Result
asSchedulable()continue as a SchedulableBuilder
minutesFromNow(Integer)only with asSchedulable().name(...).schedule(): run once, that many minutes from now, instead of on a cron

There is no retry, no finalizer and no chain for a batch, and there will not be. Those are Queueable features; a ChunkJob has all of them. Batch or Chunk?.

SchedulableBuilder and CronBuilder ​

MethodWhat it does
name(String)the scheduled job name, required
cronExpression(String) / cronExpression(CronBuilder) / cronExpression(List<CronBuilder>)when; a list schedules one job per expression
skipWhenAlreadyScheduled()no-op if a job with that name exists
schedule()returns List<Async.Result>

CronBuilder helpers: everyHour(minute), everyXHours(x, minute), everyDay(hour, minute), everyXDays(x, hour, minute), everyMonth(day, hour, minute), everyXMonths(x, day, hour, minute), buildForEveryXMinutes(x) (returns a list), and raw second(), minute(), hour(), dayOfMonth(), month(), dayOfWeek(), optionalYear(). getCronExpression() gives the string.

What you implement ​

apex
public class ImportJob extends QueueableJob {
    private List<Id> recordIds;

    public ImportJob(List<Id> recordIds) {
        this.recordIds = recordIds;
    }

    public override void work() { /* the job */ }
}
MemberOnWhen to override
void work()QueueableJobalways; the job body
void work(List<SObject> page)ChunkJobalways; one page of the run
Boolean isRetryable(Exception ex)QueueableJobveto a retry for a specific exception; default true
void onFinalFailure(Async.FailureContext ctx)QueueableJobonce, after the last attempt fails
QueueableJob cloneForDeepCopy()QueueableJobpackaged installs only; see extras/BaseQueueableJob
void resetBeforeRetry(Integer attempt)implements Async.Retryableclear state before a retry; required by retry(n) unless restoreStateOnRetry()
void resetBeforeNextChunk(Integer pageNumber)implements Async.ChunkResettableclear state before the next page; required by every ChunkJob unless restoreStateOnNextChunk()
onJobEnqueued / onJobSucceeded / onJobFailed / onRetryEnqueuedimplements Async.OnJobEnqueued etc.lifecycle events, on the job or on a class registered in LoggerClass__c
serialize(QueueableJob) / deserialize(String className, String payload)implements Async.JobSerializerpackaged installs using requeue(); see extras/AsyncJobSerializer
List<SObject> load(Set<Id> failedRecordIds)implements Async.BatchRecordLoaderpassed to requeueBatchFailures when a field list cannot reload the failed records
nothingimplements Database.RaisesPlatformEvents, on a Database.Batchablestandard marker; makes Async Lib record and log every failed scope of that batch

State between runs: answer once per question ​

A job object can run more than once, so Async Lib makes you say what happens to its fields in between. Each question has two valid answers. Give exactly one. Give neither and enqueue() or chain() throws, synchronously.

The jobQuestionAnswer in the classOr answer on the builder
uses retry(n)what carries over between attemptsimplements Async.Retryable with resetBeforeRetry(Integer attempt).restoreStateOnRetry()
is a ChunkJob (always)what carries over between pagesimplements Async.ChunkResettable with resetBeforeNextChunk(Integer pageNumber).restoreStateOnNextChunk()
  • The interfaces are not mandatory and neither are the builder calls. One of the two per row is.
  • A ChunkJob with retry(n) has both questions to answer.
  • An empty method body is a real answer: "keep whatever is there". Use it for a job with no fields, and for one that accumulates on purpose, such as a running total across pages.
  • A plain QueueableJob without retry(n) answers nothing.
  • When writing a new job, default to the interface. The builder calls take a deep copy, which on a packaged install needs extras/BaseQueueableJob or BaseChunkJob. The interfaces need nothing.

Base classes: QueueableJob.Finalizer for a job attached with attachFinalizer(). Callouts are a marker, implements Database.AllowsCallouts, on any of them.

Inside work() of a ChunkJob, getRun() gives currentPageNumber(), hasRemainingPages(), totalSize, chunkSize and remainingWorkSummary().

What you get back ​

TypeFields
Async.ResultsalesforceJobId, customJobId, asyncType, job, queueableChainState
Async.QueueableChainStatejobs, nextSalesforceJobId, nextCustomJobId, enqueueType
Async.QueueableJobContextcurrentJob, queueableCtx, finalizerCtx
Async.JobContextcustomJobId, className, salesforceJobId, chainId, priority, retryAttempt, info
Async.FailureContextretryOutcome, failure (type, message, stackTrace), customJobId, className, retryAttempt, maxRetries, retryHistory, nextAttemptDelayMinutes, info, asyncType, salesforceJobId, and for a batch failure batchPhase, batchScope, batchScopeTruncated
Async.RequeueSummaryrequeued, skipReasonByResultId, enqueueResult
Async.OutcomeSUCCESS, FAILURE, COMPLETED
Async.RetryOutcomeNOT_CONFIGURED, NOT_RETRYABLE, EXHAUSTED
Async.AsyncTypeQUEUEABLE, BATCHABLE, SCHEDULABLE

Configuration: QueueableJobSetting__mdt ​

One record named All applies to every job; a record whose QueueableJobName__c is a class name applies to that job. Wrong values degrade and record why, they never stop a job.

FieldTypeEffect
IsDisabled__cCheckboxthe job is skipped with SKIPPED_DISABLED
CreateResult__cCheckboxwrite an AsyncResult__c row for every outcome; for a batch, one row per failed scope
MaxRetries__cNumberdefault retries, 0 to 10; only applied to jobs that declare how state resets
BackoffStrategy__cTextFIXED, EXPONENTIAL, EXPONENTIAL_JITTER
BackoffBaseMinutes__cNumberbase for the strategy
RetryableExceptions__cTextcomma-separated exception type names
LoggerClass__cTexta global class implementing the lifecycle interfaces; batch failures arrive on OnJobFailed
StoreJobPayload__cPicklist Yes/Nostore a snapshot for requeue(); No on a job beats Yes on All
JobSerializerClass__cTexta global Async.JobSerializer, packaged installs only

In tests, inject them: AsyncMock.jobSettings(new List<QueueableJobSetting__mdt>{ ... }).

AsyncResult__c ​

One row per job, written after its last attempt, when CreateResult__c is on or a payload is stored. A batch writes one row per failed scope and nothing for a successful one. Read access through the AsyncResultAccess permission set.

FieldHolds
Status__cCOMPLETED, FAILED, SKIPPED_DEPENDENCY, SKIPPED_CHAIN_STOPPED, SKIPPED_CHUNK_STOPPED, SKIPPED_EXPLICIT, SKIPPED_DISABLED, FRAMEWORK_ERROR
ClassName__c, CustomJobId__c, SalesforceJobId__c, ChainId__cidentity
Result__c, ExceptionType__c, ExceptionMessage__coutcome
RetryAttempts__c, RetryHistory__cone line per attempt, plus configuration warnings
DependsOnResult__c, RequiredOutcome__c, ActualOutcome__c, SkipReason__cwhy a dependent job ran or was skipped
JobPayload__c, PayloadSize__c, RequeueStatus__c, RequeuedFrom__crequeue
BatchPhase__c, BatchScope__c, BatchScopeTruncated__cbatch failures only: START / EXECUTE / FINISH, the failed record ids, and whether Salesforce cut that list short

Old rows do not delete themselves. Schedule AsyncResultCleanupBatch with failedOlderThanDays(n) and/or othersOlderThanDays(n).

AsyncMock ​

CallWhat it does
AsyncMock.whenQueueable(mockId).thenReturn(ctx | jobId) / .thenThrow(ex)what the job sees, or fails with, when it runs
AsyncMock.whenFinalizer(mockId).thenReturn(ctx | ParentJobResult) / .thenThrow(ex)what the finalizer sees
AsyncMock.whenQueueableDefault() / whenFinalizerDefault()fallback for jobs without a matching mockId
AsyncMock.jobSettings(List<QueueableJobSetting__mdt>)inject Custom Metadata
AsyncMock.reset()clear everything
new AsyncMock.MockQueueableContext().setJobId(id) / new AsyncMock.MockFinalizerContext().setResult(r).setException(ex)hand-built contexts for calling work() directly

Chain several thenReturn calls to script successive invocations.

Recipes ​

One job ​

apex
Async.queueable(new ImportJob(recordIds)).enqueue();

A chain where the second job runs only if the first succeeded ​

apex
Async.queueable(new ExtractJob())
    .chain(new TransformJob())
        .dependsOn(Async.afterPrevious().succeeded())
    .chain(new NotifyJob())
        .dependsOn(Async.afterPrevious().finished())
    .enqueue();

Retry with backoff, state cleared between attempts ​

apex
public class SyncJob extends QueueableJob implements Async.Retryable {
    private List<Id> synced = new List<Id>();

    public override void work() { /* may throw CalloutException */ }

    public void resetBeforeRetry(Integer attempt) {
        synced.clear();
    }

    public override Boolean isRetryable(Exception ex) {
        return !ex.getMessage().contains('401');
    }
}

Async.queueable(new SyncJob())
    .retry(3)
    .backoff(Backoff.exponential(1))
    .retryOn(CalloutException.class)
    .enqueue();

Many records, one page per transaction ​

apex
public class RecalcJob extends ChunkJob implements Async.ChunkResettable {
    public override void work(List<SObject> page) {
        update page;
    }

    public void resetBeforeNextChunk(Integer pageNumber) {
    }
}

Async.chunk(new RecalcJob(), ChunkSource.query('SELECT Id FROM Account WHERE Recalc__c = true'))
    .chunkSize(200)
    .enqueue();

Schedule ​

apex
Async.queueable(new NightlyJob())
    .asSchedulable()
    .name('Nightly')
    .cronExpression(new CronBuilder().everyDay(2, 0))
    .skipWhenAlreadyScheduled()
    .schedule();

React to a final failure, on the job or org-wide ​

apex
public class ImportJob extends QueueableJob {
    public override void work() { /* ... */ }

    public override void onFinalFailure(Async.FailureContext ctx) {
        insert new IntegrationError__c(Message__c = ctx.failure.message, Attempts__c = ctx.retryAttempt);
    }
}

global class AsyncJobLogger implements Async.OnJobFailed {
    public void onJobFailed(Async.FailureContext ctx) {
        Logger.error(ctx.className + ' failed: ' + ctx.failure.message);
    }
}
// then QueueableJobSetting__mdt.LoggerClass__c = 'AsyncJobLogger' on the All record

Several services, one chain ​

apex
// in each service
Async.queueable(new SendWelcomeJob(accountIds)).chain();
Async.queueable(new ReviewContactJob(contactIds)).chain();

// once, as the last line of the entry point
Async.queueable().enqueue();

A batch you keep, with its failures recorded and requeued ​

apex
public class AccountSyncBatch implements Database.Batchable<SObject>, Database.RaisesPlatformEvents {
    public Database.QueryLocator start(Database.BatchableContext bc) { /* ... */ }
    public void execute(Database.BatchableContext bc, List<Account> scope) { /* ... */ }
    public void finish(Database.BatchableContext bc) { /* ... */ }
}
// QueueableJobSetting__mdt.CreateResult__c = true on All, or on a record named AccountSyncBatch

Id jobId = Async.batchable(new AccountSyncBatch()).scopeSize(200).execute().salesforceJobId;

// later, after fixing what broke
Async.requeueBatchFailures(new AccountSyncBatch(), jobId, new List<SObjectField>{ Account.Name });

Test a job ​

apex
@IsTest
static void failsCleanly() {
    AsyncMock.whenQueueable('import').thenThrow(new CalloutException('down'));

    Test.startTest();
    Async.queueable(new ImportJob(ids)).mockId('import').continueOnJobExecuteFail().enqueue();
    Test.stopTest();

    Assert.areEqual(1, [SELECT COUNT() FROM IntegrationError__c]);
}

Gotchas ​

Things that read as bugs and are not, and things agents get wrong on the first try.

  • retry(n) throws at enqueue unless the job says what happens to its state. Implement Async.Retryable or call restoreStateOnRetry(), one or the other. An empty resetBeforeRetry body is a valid answer. Same for every ChunkJob, retry or not, with Async.ChunkResettable or restoreStateOnNextChunk(). A bare extends ChunkJob with no builder call does not enqueue. The table is under What you implement. Job State Between Runs.
  • More than 50 jobs is fine. The chain switches to a scheduled starter past the platform's 50-queueable limit on its own. Do not batch enqueues by hand.
  • Jobs chained inside work() join the running chain. Use Async.queueable(...).chain() or .enqueue() from inside a job, never System.enqueueJob, or you spend the transaction's single enqueue slot on a job the chain does not know about.
  • A failed job does not stop the chain. It stops its own work. Chain control is dependsOn(...), Async.stopChain() or Async.skipJob(...), and the safe place to call the last two is a finalizer. Failures and the Chain.
  • Invalid conversion from runtime type ... to Datetime in the debug log is expected. The framework throws and catches it once per job to read the class name. Expected Exceptions.
  • deepClone() and restoreStateOn*() need a base class on a packaged install. Copy extras/BaseQueueableJob and BaseChunkJob. Source deploys need nothing. Deep Clone in Packages.
  • Inside a QueueableJob subclass write Async.Backoff.exponential(1), not Backoff.exponential(1). The inherited backoff field shadows the type there.
  • Result rows are opt-in. Nothing is written unless CreateResult__c is on, or a payload is stored and the job failed or was skipped. Do not query AsyncResult__c and expect a row.
  • requeue() replays data, not intent. The payload is the job as it was enqueued, rebuilt by the class as it is now. If the fix renamed a field or changed what one means, enqueue fresh. Requeue.
  • Anything registered by name in Custom Metadata is global on a packaged install.LoggerClass__c, JobSerializerClass__c. Class only, methods stay public.
  • A ChunkJob is not a batch. One page per transaction, in sequence, inside the chain, with retry and dependencies. Async.batchable(...) is a plain Database.Batchable with a fluent wrapper. Chunk.
  • Default to Async.chunk, not Async.batchable, for new work over many records. Batches get no retry, no finalizer and no chaining, on purpose: a batch has no Transaction Finalizer to build them on. Write a batch only for sharing recalculation, a non-record scope, or more than 100 million cursor rows a day. Batch or Chunk?.
  • chain() alone runs nothing. Something has to call enqueue() before the transaction ends, and a chain that is never started is dropped without an error. Services call chain(), the entry point ends with Async.queueable().enqueue(). Chaining Across Services.
  • A batch failure is recorded only if the batch implements Database.RaisesPlatformEvents. The row is written by a platform event trigger about a second after the scope fails, so the last one may not be there yet inside the batch's own finish(). In a test, catch what Test.stopTest() rethrows and call Test.getEventBus().deliver(). Batch Failures.
  • requeueBatchFailures needs to be told what to reload. A failure stores ids only. Pass the fields execute() reads, an empty list for Id only, or an Async.BatchRecordLoader. null throws. It does not call finish() and does not retry on its own. A loader must be a saved class, not one declared inside an anonymous Apex script.
  • A logger hears batch failures as the Automated Process user. Log there; do not enqueue work.
  • delay() and asyncOptions() are exclusive, and delay tops out at 10 minutes.