Skip to content

Queueable API

Apex classes QueueableBuilder.cls, QueueableManager.cls, and QueueableJob.cls.

New to async jobs? See Standard Apex vs Async Lib for how this maps to a plain Queueable. For testing patterns and best practices, see Testing Async Jobs.

Common QueueableJob class example:

Extend QueueableJob and put your logic in work() instead of implementing Queueable.execute().

apex
public class AccountProcessorJob extends QueueableJob {
  private List<Id> accountIds;

  public AccountProcessorJob(List<Id> accountIds) {
    this.accountIds = accountIds;
  }

  public override void work() {
    List<Account> accounts = [SELECT Id, Name FROM Account WHERE Id IN :accountIds];
    for (Account acc : accounts) {
      acc.Description = 'Processed';
    }
    update accounts;
  }
}

Common Queueable example:

apex
Async.Result result = Async.queueable(new AccountProcessorJob(accountIds))
	.priority(5)
	.delay(2)
	.continueOnJobExecuteFail()
	.enqueue();

Returns result.customJobId containing AccountProcessorJob's unique Custom Job Id.

Common Finalizer class example:

A finalizer runs after the job completes, whether it succeeded or threw. Extend QueueableJob.Finalizer and read the outcome from the FinalizerContext.

apex
public class ProcessorFinalizer extends QueueableJob.Finalizer {
  public override void work() {
    FinalizerContext finalizerCtx = Async.getQueueableJobContext().finalizerCtx;
    if (finalizerCtx.getResult() == ParentJobResult.SUCCESS) {
      System.debug('Job succeeded');
    } else {
      System.debug('Job failed: ' + finalizerCtx.getException().getMessage());
    }
  }
}

Expected exception in debug logs

Seeing Invalid conversion from runtime type ... to Datetime in your logs? That exception is thrown and caught on purpose by the framework and is harmless. See Expected Exceptions in Debug Logs.

Callouts

Add implements Database.AllowsCallouts to any job that calls out. This is the standard Salesforce marker interface, the same one you put on any Queueable, not an Async Lib invention.

It works on every job type, on QueueableJob, on ChunkJob, on a finalizer, and on the base classes used for packaged installs.

apex
public class SyncJob extends QueueableJob implements Database.AllowsCallouts {
    public override void work() {
        HttpResponse response = new Http().send(request);
    }
}

Callout capability survives cloning, so a retried job and every chunk page can still call out. The copy is always the same concrete class.

QueueableJob.AllowsCallouts

extends QueueableJob.AllowsCallouts is the pre-3.0 form and still works. It is an empty class that does nothing but implement Database.AllowsCallouts for you.

Prefer the marker on new code. A job can only extend one class, so the marker composes with whatever base your job already needs, while the base class does not. QueueableJob.Finalizer is different and stays a base class, because the framework tests for that type rather than for a capability.

Methods

The following are methods for using Async with Queueable jobs:

INIT

Build

Execute

Context

Chain control

Override hooks — methods you override on your QueueableJob subclass (not fluent builder calls)

INIT

queueable

Constructs a new QueueableBuilder instance with the specified queueable job.

Signature

apex
Async queueable(QueueableJob job);

Example

apex
Async.queueable(new MyQueueableJob());

queueable (no args)

Constructs an empty QueueableBuilder so jobs can be added incrementally via chain(QueueableJob). Useful when the number of jobs to enqueue depends on runtime conditions — calling enqueue() on a builder with zero jobs is a safe no-op.

Signature

apex
QueueableBuilder queueable();

Example

apex
QueueableBuilder builder = Async.queueable();
if (needsJob1) {
	builder.chain(new Job1());
}
if (needsJob2) {
	builder.chain(new Job2());
}
if (needsJob3) {
	builder.chain(new Job3());
}
// Enqueues whatever was added; does nothing if none were.
builder.enqueue();

Build

asyncOptions

Sets AsyncOptions for the queueable job. Cannot be used with delay().

Signature

apex
QueueableBuilder asyncOptions(AsyncOptions asyncOptions);

Example

apex
AsyncOptions options = new AsyncOptions();
Async.queueable(new MyQueueableJob())
	.asyncOptions(options);

delay

Sets a delay in minutes before the job executes. Cannot be used with asyncOptions().

Signature

apex
QueueableBuilder delay(Integer delay);

Example

apex
Async.queueable(new MyQueueableJob())
	.delay(5);  // Execute in 5 minutes

priority

Sets the priority for the queueable job. Lower numbers = higher priority.

Signature

apex
QueueableBuilder priority(Integer priority);

Example

apex
Async.queueable(new MyQueueableJob())
	.priority(1);  // High priority

continueOnJobEnqueueFail

Allows the job chain to continue even if this job fails to enqueue.

Signature

apex
QueueableBuilder continueOnJobEnqueueFail();

Example

apex
Async.queueable(new MyQueueableJob())
	.continueOnJobEnqueueFail();

continueOnJobExecuteFail

Controls what happens to this job's own work when work() throws. It does not control the chain. Jobs that were already in the chain still run, because chain progression is driven by the finalizer, which always fires. To stop or branch the chain on failure, use dependsOn(...).

  • Without it (default): the exception propagates, so the platform rolls back this job's DML and the AsyncApexJob is marked Failed.
  • With it: the exception is caught, so the partial DML this job did before the failure is committed and the AsyncApexJob is marked Completed.

This flag also decides the fate of anything the job chained inside work(). Without it the transaction rolls back and those jobs are discarded; with it the transaction commits and they run. See What a Failed Job Does to the Chain.

In both cases the job is still recorded as failed for dependsOn(...) outcome checks, and any retry(...) still applies.

Signature

apex
QueueableBuilder continueOnJobExecuteFail();

Example

apex
Async.queueable(new MyQueueableJob())
	.continueOnJobExecuteFail();

rollbackOnJobExecuteFail

If work() throws, rolls this job's DML back to a savepoint taken before it ran. Like continueOnJobExecuteFail() it handles the failure (the exception is not re-thrown), so the chain keeps going. The difference is that the partial DML is discarded instead of committed. You do not need to also set continueOnJobExecuteFail().

Because the job's work is discarded, so is anything it chained inside work(). Note that the AsyncApexJob reads Completed here while those jobs are gone; the AsyncResult__c row is the one that says FAILED. See What a Failed Job Does to the Chain.

Signature

apex
QueueableBuilder rollbackOnJobExecuteFail();

Example

apex
Async.queueable(new MyQueueableJob())
	.rollbackOnJobExecuteFail();

retry

Opts the job into automatic retry on execution failure. maxRetries is the number of retries after the first run (so retry(3) runs the job up to 4 times total). Retry is off by default, so without this call a failed job is never retried. maxRetries must not exceed the framework safety limit of 10; a higher value (whether passed to retry(...) or configured via QueueableJobSetting__mdt) throws an exception.

On each failed attempt the framework re-enqueues a fresh clone of the job with an incremented attempt counter. Jobs the failed attempt chained, and any stopChain() or skipJob(...) it called, do not reach the next attempt (see What a Failed Job Does to the Chain). By default every exception is retried. Narrow retries to the failures worth re-running with the coarse type filter retryOn(...) and/or the fine-grained isRetryable(Exception) override — when both are present, both must pass (see retryOn). Retry composes with continueOnJobExecuteFail: once retries are exhausted the chain behaves exactly as it would for a non-retry job.

Retry is built on the finalizer, so it also covers uncatchable failures (e.g. governor LimitException) that no try/catch can see: the finalizer runs in a fresh transaction, classifies the failure from FinalizerContext.getException(), and re-enqueues if eligible. Note that an uncatchable failure rolls back the whole transaction automatically — your rollbackOnJobExecuteFail / continueOnJobExecuteFail flags do not run in that case because there is no catch.

When the job exhausts its retries, the per-attempt history (attempt number, exception, computed delay) is aggregated into AsyncResult__c.RetryHistory__c (when result creation is enabled via QueueableJobSetting__mdt.CreateResult__c).

Idempotency

A retried job re-runs work(), so make retried jobs idempotent. State the previous attempt accumulated is carried into the next one, and deepClone() does not clear it, because the clone is taken after work() already mutated the job.

Async Lib will not let you leave this undecided. retry(n) requires either resetBeforeRetry(Integer) via Async.Retryable, or restoreStateOnRetry() to replay the job from the state it had at enqueue. See Job State Between Runs.

Signature

apex
QueueableBuilder retry(Integer maxRetries);

Example

apex
Async.queueable(new MyQueueableJob())
	.retry(3)
	.enqueue();

backoff

Sets the delay strategy between retries. Salesforce caps delayed enqueue at 10 integer minutes, so every strategy is expressed in minutes and clamped to [0, 10]. Without a backoff, retries are re-enqueued immediately.

StrategyDelay for attempt n (base b)
Backoff.fixed(b)b
Backoff.exponential(b)b * 2^(n-1) (e.g. 1 → 1, 2, 4, 8, 10)
Backoff.exponentialWithJitter(b)exponential plus random 0..b jitter

Signature

apex
QueueableBuilder backoff(Backoff backoff);

Example

apex
Async.queueable(new MyQueueableJob())
	.retry(3)
	.backoff(Backoff.exponential(1)) // 1m, 2m, 4m
	.enqueue();
Configuring backoff inside the job class

Retry settings often belong next to the job rather than on every enqueue call site. Inside a QueueableJob subclass, use Async.Backoff instead of the bare Backoff factories:

apex
public without sharing class MyQueueableJob extends QueueableJob {
	public MyQueueableJob() {
		this.maxRetries = 5;
		this.backoff = Async.Backoff.exponentialWithJitter(1);
	}

	public override void work() { /* ... */ }
}

Async.Backoff exposes the same three strategies and returns the same Backoff object, so it is interchangeable with .backoff(...) on the builder.

Backoff.exponentialWithJitter(1) does not compile in that position. Apex name resolution is case-insensitive, so inside the subclass the inherited backoff field hides the Backoff type and the compiler reads the call as an instance method on the field:

Static method cannot be referenced from a non static context:
Backoff Backoff.exponentialWithJitter(Integer)

Qualifying through Async avoids the clash, and works the same whether the library is installed as a package or deployed as source.

retryOn

Restricts retry to the listed exception types (matched by full name or short name, so CalloutException matches System.CalloutException). Call multiple times to add more. When omitted, retry applies to any exception type.

retryOn(...) and isRetryable(Exception) are two independent gates that are AND-ed: the type filter is coarse, the override is fine-grained, and a retry happens only when both pass. This means the override can only narrow the type filter, never broaden it — if retryOn excludes a type, no override can make it retryable. For pure-OR logic, omit retryOn and do all matching inside isRetryable.

Signature

apex
QueueableBuilder retryOn(Type exceptionType);
QueueableBuilder retryOn(List<Type> exceptionTypes);

Example

apex
Async.queueable(new MyQueueableJob())
	.retry(3)
	.retryOn(DmlException.class)
	.retryOn(CalloutException.class)
	.enqueue();

dependsOn

Makes the job conditional on another job's outcome. When the chain reaches a dependent job whose dependency outcome does not match, that job is skipped, along with anything that transitively depends on it. The rest of the chain still runs. This is how you stop or branch a chain on failure; the ...OnJobExecuteFail flags do not affect chain progression.

The dependency target is identified by its auto-generated, always-unique customJobId, so it is collision-proof even when the same code builds the chain in a loop. Build the dependency with one of:

BuilderTarget
Async.afterPrevious()the immediately-preceding chained job
Async.after(Async.Result r)the job whose Result you captured when adding it
Async.after(String id)an explicit customJobId

...combined with a required outcome:

OutcomeRuns the dependent when the target…
.succeeded()completed without throwing
.failed()threw during work()
.finished()ran either way (success or failure)

A job counts as failed for these checks whenever its work() throws, no matter how continueOnJobExecuteFail or rollbackOnJobExecuteFail are set. Dependency targets must appear earlier in the chain than the jobs that depend on them.

Signature

apex
QueueableBuilder dependsOn(Async.Dependency dependency);

Example

apex
// Linear gating reads cleanest with afterPrevious()
Async.queueable(new ExtractJob())
	.chain(new TransformJob())
		.dependsOn(Async.afterPrevious().succeeded())
	.enqueue();

// Fan-in / non-adjacent: capture the dependency's Result and reference it
Async.Result extract = Async.queueable(new ExtractJob()).chain();
Async.queueable(new TransformJob())
	.dependsOn(Async.after(extract).succeeded())
	.chain();
Async.queueable(new AlertOpsJob())
	.dependsOn(Async.after(extract).failed())
	.enqueue();

deepClone

Clones provided QueueableJob by value for all the member variables. By default only primitive member variables (String, Boolean, ...) are cloned by value. Deeper explanation is here.

Package Usage

When using Async Lib as a package (btcdev namespace), deep clone requires overriding cloneForDeepCopy() in your subclass. See Deep Clone in Packages.

Signature

apex
QueueableBuilder deepClone();

Example

apex
Async.queueable(new MyQueueableJob())
	.deepClone();

restoreStateOnRetry

Replays every retry from the state the job had when it was enqueued, instead of from whatever the failed attempt left behind. Async Lib takes a deep copy at enqueue and restores your fields from it before each attempt.

Only fields you declared are restored. retryAttempt, the retry history and the backoff delay carry forward, so the retry still knows which attempt it is.

This is the alternative to implementing Async.Retryable. A job with retry(n) needs one of the two, or it throws at enqueue.

Package Usage

The restore takes a deep copy, so on a namespaced install the job needs a cloneForDeepCopy() override. Write it per job, or extend a base class that does it for you, whichever suits. See Deep Clone in Packages. resetBeforeRetry() needs neither.

Signature

apex
QueueableBuilder restoreStateOnRetry();

Example

apex
Async.queueable(new MyQueueableJob())
	.retry(3)
	.restoreStateOnRetry()
	.enqueue();

chain

Adds the Queueable Job to the chain without enqueing it. All jobs in chain will be enqueued once enqueue() method is invoked.

Signature

apex
QueueableBuilder chain();

Example

apex
Async.Result result = Async.queueable(new MyQueueableJob())
	.chain();

Returns result.customJobId containing MyQueueableJob's unique Custom Job Id.

chain next job

Adds the Queueable Job to the chain after previous job. All jobs in chain will be enqueued once enqueue() method is invoked.

Signature

apex
QueueableBuilder chain(QueueableJob job);

Example

apex
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.

chunk next run

Adds a chunked run to the chain after the previous job. The run pages through its source and every page finishes before the next chain member starts. See Chunk API.

Only needed when the run follows a job you already chained. A run that starts the chain uses Async.chunk(...) directly, with no queueable() in front of it.

Signature

apex
ChunkBuilder chunk(ChunkJob job, ChunkSource source);

Example

apex
// Standalone run: no queueable() needed
Async.chunk(new AccountRecalcJob(), ChunkSource.of(records))
	.chunkSize(200)
	.enqueue();

// After an earlier job in the same chain
Async.queueable(new MyQueueableJob())
	.chunk(new AccountRecalcJob(), ChunkSource.of(records))
	.chunkSize(200)
	.chain(new MyOtherQueueableJob())
	.enqueue();

asSchedulable

Converts the queueable builder to a schedulable builder for cron-based scheduling. See Schedulable API for scheduling options.

Signature

apex
SchedulableBuilder asSchedulable();

Example

apex
Async.queueable(new MyQueueableJob())
	.asSchedulable();

mockId

Sets a mock identifier for testing with AsyncMock. When the job executes during a test, the framework will inject the corresponding mock context. See AsyncMock API for details.

Signature

apex
QueueableBuilder mockId(String mockId);

Example

apex
// For queueable context mocking
AsyncMock.whenQueueable('account-creator')
	.thenReturn(new AsyncMock.MockQueueableContext());

Async.queueable(new AccountCreatorJob())
	.mockId('account-creator')
	.enqueue();

// For finalizer mocking, use mockId when attaching finalizer inside work()
// See AsyncMock API for finalizer patterns

Execute

enqueue

Enqueues the queueable job with the configured options. Returns an Async.Result.

Signature

apex
Async.Result enqueue();

Example

apex
Async.Result result = Async.queueable(new MyQueueableJob())
	.priority(5)
	.enqueue();

Result properties:

PropertyDescription
salesforceJobIdSalesforce Job Id of the actually-enqueued first-chain Queueable Job or Initial Queueable Chain Schedulable. null when enqueue() is called on an empty Async.queueable() builder with no jobs added.
customJobIdUnique Custom Job Id
asyncTypeAsync.AsyncType.QUEUEABLE
jobThe QueueableJob instance that the builder was finalized with. Useful for inspecting post-enqueue state, especially the cloned instance when .deepClone() was used. null when enqueue() is called on an empty Async.queueable() builder.
queueableChainStateChain state object (see below)

queueableChainState properties:

PropertyDescription
jobsAll jobs in chain including finalizers and processed jobs
nextSalesforceJobIdSalesforce Job Id that will run next from chain
nextCustomJobIdCustom Job Id that will run next from chain
enqueueTypeHow the chain was enqueued: EXISTING_CHAIN, NEW_CHAIN, or INITIAL_QUEUEABLE_CHAIN_SCHEDULABLE

attachFinalizer

Attaches a finalizer job to run after the current job completes. Can only be called within a QueueableChain context.

Signature

apex
Async.Result attachFinalizer();

Example

apex
// Inside a QueueableJob's work() method
Async.Result result = Async.queueable(new MyFinalizerJob())
	.attachFinalizer();

Returns result.customJobId containing the finalizer's unique Custom Job Id.

Context

getQueueableJobContext

Gets the current queueable job context, providing access to job information and Salesforce QueueableContext.

Signature

apex
Async.QueueableJobContext getQueueableJobContext();

Example

apex
Async.QueueableJobContext ctx = Async.getQueueableJobContext();

Context properties:

PropertyDescription
ctx.currentJobCurrent QueueableJob instance
ctx.queueableCtxSalesforce QueueableContext
ctx.finalizerCtxSalesforce 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.

Signature

apex
Id getQueueableChainSchedulableId();

Example

apex
Id schedulableId = Async.getQueueableChainSchedulableId();

Returns the Id of the Initial Queueable Chain Schedulable.

getCurrentQueueableChainState

Gets details about the current Queueable Chain.

Signature

apex
QueueableChainState getCurrentQueueableChainState();

Example

apex
QueueableChainState currentChain = Async.getCurrentQueueableChainState();

Chain state properties:

PropertyDescription
jobsAll jobs in chain including processed ones and finalizers
nextSalesforceJobIdSalesforce Job Id that will run next (empty if chain not enqueued)
nextCustomJobIdCustom Job Id that will run next from chain
enqueueTypeEmpty until set during enqueue() method

Chain control

dependsOn(...) is the declarative way to skip jobs based on another job's outcome. For imperative control, where you decide at runtime (based on the specific exception) whether to stop or skip, call these from a running job or, better, from a finalizer.

Reacting to unhandlable failures

When work() throws, your code in work() never finishes, and an uncatchable governor-limit failure kills the transaction entirely. A QueueableJob.Finalizer runs either way, with FinalizerContext.getResult() reporting UNHANDLED_EXCEPTION, so it is the right place to stop or reshape the chain after a failure. The framework reconciles the failure from the FinalizerContext, so dependsOn(...) and your finalizer logic both see the correct outcome even when our own try/catch could not run.

apex
public class GuardFinalizer extends QueueableJob.Finalizer {
  public override void work() {
    FinalizerContext fctx = Async.getQueueableJobContext().finalizerCtx;
    if (fctx.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) {
      Async.stopChain();
    }
  }
}

stopChain

Skips every remaining (unprocessed) job in the current chain. Nothing else runs.

Called from a work() whose transaction then dies, the stop is undone along with everything else that attempt did to the chain. To stop on failure, catch the exception yourself or stop from a finalizer. See What a Failed Job Does to the Chain.

Signature

apex
void stopChain();

Example

apex
Async.stopChain();

skipJob

Skips the job with the given customJobId and any finalizers attached to it. Jobs that dependsOn the skipped job are skipped in turn. Throws if no job in the chain has that id.

Like stopChain(), a skip is undone if the attempt that called it did not commit. See What a Failed Job Does to the Chain.

Signature

apex
void skipJob(String customJobId);

Example

apex
Async.skipJob(notificationsResult.customJobId);

Override hooks

These are public virtual methods you override on your own QueueableJob subclass — they are not fluent builder calls.

isRetryable

Override to decide, per exception, whether a failed job should retry. The default returns true (every exception is retryable, subject to retryOn and the retry cap). The framework evaluates it where the live exception exists — at the catch site for handled exceptions, or from the finalizer's FinalizerContext.getException() for uncatchable ones — so you get the full exception object (getMessage(), getCause(), instanceof), not just a type name. This is the place to distinguish transient failures that share a type, e.g. retry an "UNABLE_TO_LOCK_ROW" DmlException but not a validation-rule one.

Combined with retryOn, both must pass (AND). Keep the override side-effect free (no DML/SOQL) — it runs inside the failure path, and if it throws, the framework treats the job as not retryable and records the override failure in RetryHistory__c.

Signature

apex
public virtual Boolean isRetryable(Exception ex);

Example

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

  public override Boolean isRetryable(Exception ex) {
    // retryOn(DmlException.class) gates the type; veto the permanent ones here
    return !(ex instanceof DmlException &&
    ex.getMessage().containsIgnoreCase('FIELD_CUSTOM_VALIDATION'));
  }
}

resetBeforeRetry

Declared by Async.Retryable. Runs on the retry clone before the next attempt, after the framework has reset its own bookkeeping. Clear or recreate your own members here.

The framework re-enqueues a clone of the failed job, and a shallow clone copies object members by reference, so anything that accumulated during the failed run (most commonly a Unit of Work holding registered records) is carried into the retry and can cause duplicate or stale DML.

Any job configuring retry(n) must either implement this or call restoreStateOnRetry(), otherwise it throws at enqueue. An empty body is a valid answer when the job holds nothing worth clearing.

Signature

apex
public interface Retryable {
  void resetBeforeRetry(Integer attempt);
}

Example

apex
public class SyncContactsJob extends QueueableJob implements Async.Retryable {
  private MyUnitOfWork uow = new MyUnitOfWork();

  public override void work() {
    /* registers into uow, then commits */
  }

  public void resetBeforeRetry(Integer attempt) {
    this.uow = new MyUnitOfWork(); // fresh, empty, drop the failed run's registrations
  }
}

See Job State Between Runs for the full picture, including the chunk equivalent.

resetBeforeNextChunk

Declared by Async.ChunkResettable. The chunk equivalent of resetBeforeRetry. Runs before the run advances to the next page, receiving the page number it is preparing.

Every page re-runs the same job object, so per-page buffers carry over unless you clear them. State you want to survive the whole run, such as a running total, is exactly what you leave alone here.

Every ChunkJob must either implement this or call restoreStateOnNextChunk(), otherwise it throws at enqueue. An empty body is a valid answer.

Signature

apex
public interface ChunkResettable {
  void resetBeforeNextChunk(Integer pageNumber);
}

Example

apex
public class ImportChunk extends ChunkJob implements Async.ChunkResettable {
  private List<Id> pending = new List<Id>();
  private Integer totalProcessed = 0;

  public override void work(List<SObject> page) { ... }

  public void resetBeforeNextChunk(Integer pageNumber) {
    pending.clear();   // totalProcessed deliberately survives the whole run
  }
}

resetForRetry DEPRECATED

This method is never called

As of 3.0.0 resetForRetry() does nothing. Overriding it has no effect and your reset logic will silently not run.

Move it:

apex
// Before, no longer runs
public override void resetForRetry() {
    inserted.clear();
}

// After
public class MyJob extends QueueableJob implements Async.Retryable {
    public void resetBeforeRetry(Integer attempt) {
        inserted.clear();
    }
}

You will not miss the migration by accident: any job that configures retry(n) without declaring Async.Retryable or restoreStateOnRetry() throws at enqueue.

The method still exists only because it cannot be removed. Dropping a global member makes the package install fail in every org that referenced it.

apex
public virtual void resetForRetry(); // deprecated, no-op

onFinalFailure

Override to react when a job has failed and will not run again. Custom logging, alerting, a compensating record, anything you would otherwise have to bolt on with a separate finalizer job. Default is a no-op.

It fires once per job, not once per attempt. A job with retry(2) that fails three times calls it a single time, after the last attempt, so a logger here does not multiply. Retry detail for the attempts you did not see is on the context as retryHistory.

It does not fire for jobs that were skipped (SKIPPED_DEPENDENCY, SKIPPED_CHAIN_STOPPED, SKIPPED_CHUNK_STOPPED). Those never ran, so they never failed. Query AsyncResult__c if you need to see them.

ChunkJob extends QueueableJob, so a chunk run calls it once per failed page.

The override runs inside the framework's finalizer, before the next job is enqueued. DML is fine and is the point. If it throws, the framework records the failure in RetryHistory__c and carries on rather than killing the chain, the same way a throwing isRetryable is handled. Do not call System.enqueueJob directly in here; use Async.queueable(...) so the job joins the chain instead of spending the finalizer's single enqueue slot.

Signature

apex
public virtual void onFinalFailure(Async.FailureContext failureCtx);

Async.FailureContext

PropertyTypeDescription
retryOutcomeAsync.RetryOutcomeHow the retry question was settled. See below.
failureQueueableJob.FailureInfotype, message and stackTrace of the exception that ended the job.
customJobIdStringCorrelates to AsyncResult__c.CustomJobId__c.
classNameStringThe job class, namespace-qualified in a packaged install.
retryAttemptIntegerAttempts already made, 0 when the first run was the only one.
maxRetriesIntegerThe configured cap, 0 when no retry policy applied.
retryHistoryStringOne line per attempt with its exception type and message.

Async.RetryOutcome

ValueMeaning
NOT_CONFIGUREDNo retry(n) and no CMDT default, so the first failure was final.
NOT_RETRYABLEretryOn excluded the type, or isRetryable returned false.
EXHAUSTEDRetried up to the cap and still failed.

There is deliberately no success value. A FailureContext only exists on a job that failed for good, so a retry that eventually worked never produces one.

Example

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

  public override void onFinalFailure(Async.FailureContext failureCtx) {
    insert new IntegrationError__c(
      Job__c = failureCtx.className,
      CorrelationId__c = failureCtx.customJobId,
      RetryOutcome__c = failureCtx.retryOutcome.name(),
      ExceptionType__c = failureCtx.failure?.type,
      Message__c = failureCtx.failure?.message,
      StackTrace__c = failureCtx.failure?.stackTrace,
      Attempts__c = failureCtx.retryAttempt
    );
  }
}

Using retryOutcome to tell "it broke" from "it stayed broken"

Without it, every final failure looks the same and you cannot tell a transient outage from a bug. Each value answers a different operational question:

ValueWhat actually happenedUsual reaction
NOT_CONFIGUREDRan once, failed, and no retry policy was ever set. Nobody decided this was unretryable.Record it. If it keeps happening, the job probably wants retry(n).
NOT_RETRYABLEYour own rules refused it: retryOn excluded the type, or isRetryable returned false.Retrying was never going to help. Bad data or a bug, so route it to a human.
EXHAUSTEDIt was retried up to the cap and failed every time. The dependency stayed down for the whole window.Escalate. Nothing self-healed, and retryHistory shows each attempt.
apex
public class SyncContactsJob extends QueueableJob {
  public override void work() {
    /* calls an external system */
  }

  public override Boolean isRetryable(Exception ex) {
    return !(ex instanceof CalloutException &&
    ex.getMessage().containsIgnoreCase('401'));
  }

  public override void onFinalFailure(Async.FailureContext failureCtx) {
    switch on failureCtx.retryOutcome {
      when EXHAUSTED {
        // Retried and still down, so this is an outage rather than a one-off.
        insert new Case(
          Subject = 'Contact sync down after ' + failureCtx.retryAttempt + ' attempts',
          Description = failureCtx.retryHistory,
          Priority = 'High',
          Origin = 'Async Lib'
        );
      }
      when NOT_RETRYABLE {
        // isRetryable() vetoed it: expired credentials will never fix themselves.
        insert new Case(
          Subject = 'Contact sync rejected: ' + failureCtx.failure?.type,
          Description = failureCtx.failure?.message,
          Priority = 'High',
          Origin = 'Async Lib'
        );
      }
      when else {
        insert new IntegrationLog__c(
          Job__c = failureCtx.className,
          CorrelationId__c = failureCtx.customJobId,
          Message__c = failureCtx.failure?.message
        );
      }
    }
  }
}

retryHistory is the part you cannot reconstruct

Only the final attempt is visible anywhere else. retryHistory carries one line per attempt with that attempt's exception type and message, so a job that failed three different ways still tells you the whole story:

Attempt 1: System.CalloutException - Read timed out (retry in 1m)
Attempt 2: System.CalloutException - Read timed out (retry in 2m)
Attempt 3: System.CalloutException - 503 Service Unavailable (no further retry)

Attach it to whatever you create; the individual attempts are not recorded anywhere else, because only the last one produces an AsyncResult__c row.

Note on the AsyncResult__c record

The result row is written after the hook returns, so you cannot look it up by Id from inside the override. Store customJobId on your own record and join on AsyncResult__c.CustomJobId__c later. Result tracking is off unless CreateResult__c is enabled on QueueableJobSetting__mdt, so do not depend on a row existing at all.