--- url: https://async.beyondthecloud.dev/getting-started.md --- # Getting Started Async Lib is a powerful Salesforce Apex framework that provides an elegant solution for managing asynchronous processes. It eliminates common limitations like "Too many queueable jobs" errors and offers a unified API for queueable, batchable, and schedulable jobs. ## Why Async Lib? ### Salesforce Limits ### Key Benefits * **🚀 Eliminates Queueable Limits**: Automatically handles "Too many queueable jobs" by intelligent chaining and batch overflow * **🎯 Unified API**: Single, consistent interface for all async job types (Queueable, Batchable, Schedulable) * **⚡ Smart Prioritization**: Jobs execute based on priority with automatic sorting * **🛡️ Advanced Error Handling**: Built-in error recovery, rollback options, and continuation strategies * **📊 Job Tracking**: Comprehensive tracking with custom job IDs and result records * **⚙️ Configuration-Driven**: Control job behavior through custom metadata without code changes * **🔗 Support Finalizers**: Execute cleanup logic after job completion with full context ## Core Concepts ### 1. QueueableJob Base Class All queueable jobs extend the `QueueableJob` abstract class: ```apex public class MyQueueableJob extends QueueableJob { public override void work() { // Your business logic here System.debug( 'Processing job: ' + Async.getQueueableJobContext().currentJob.customJobId ); } } ``` ### 2. Builder Pattern API All job types use a fluent builder pattern: ```apex // Queueable Job Async.queueable(new MyQueueableJob()) .priority(10) .delay(5) .enqueue(); // Batch Job Async.batchable(new MyBatchJob()) .scopeSize(100) .execute(); // Schedulable Job Async.schedulable(new MySchedulableJob()) .name('Daily Cleanup') .cronExpression('0 0 2 * * ? *') .skipWhenAlreadyScheduled() .schedule(); ``` ### 3. Automatic Job Chaining When queueable limits are reached, Async Lib automatically switches to scheduled-batch-based execution, ensuring your jobs always run without hitting Queueable platform limits. ## Your First Queueable Job Let's create a simple job that processes accounts: ### Step 1: Create Your Job Class ```apex public class AccountProcessorJob extends QueueableJob { private List accountIds; public AccountProcessorJob(List accountIds) { this.accountIds = accountIds; } public override void work() { // Get job context Async.QueueableJobContext ctx = Async.getQueueableJobContext(); System.debug('Processing job: ' + ctx.currentJob.customJobId); // Process accounts List accounts = [ SELECT Id, Name FROM Account WHERE Id IN :accountIds ]; for (Account acc : accounts) { acc.Description = 'Processed by ' + ctx.currentJob.className; } update accounts; System.debug('Processed ' + accounts.size() + ' accounts'); } } ``` ### Step 2: Enqueue the Job ```apex // Get some account IDs List accountIds = new List{ '0013000000abcdef', '0013000000ghijkl' }; // Enqueue the job Async.Result result = Async.queueable(new AccountProcessorJob(accountIds)) .priority(5) .enqueue(); System.debug('Job enqueued with ID: ' + result.customJobId); ``` ## Processing a Large Data Set One job per record blows the limits, and a `Database.Batchable` is a separate lifecycle with none of the chain tracking. `Async.chunk(...)` sits in between: it pages through a source and runs one job per page, with the same retry, backoff and `AsyncResult__c` tracking as any other job. ```apex public class AccountRecalcJob extends ChunkJob implements Async.ChunkResettable, Async.Retryable { public override void work(List chunk) { List accounts = (List) chunk; for (Account acc : accounts) { acc.Description = 'Recalculated'; } update accounts; } // This job keeps nothing between pages or attempts, and says so. public void resetBeforeNextChunk(Integer pageNumber) { } public void resetBeforeRetry(Integer attempt) { } } ``` The same job object runs once per page, and again on every retry, so Async Lib makes you state what happens to its state. Empty bodies are a valid answer when there is nothing to clear. See [Job State Between Runs](/explanations/job-state-between-runs). ```apex // In-memory records or ids Async.chunk(new AccountRecalcJob(), ChunkSource.of(accounts)) .chunkSize(200) .retry(2) .enqueue(); // Too large to hold in memory? Page a SOQL cursor instead Async.chunk(new AccountRecalcJob(), ChunkSource.query('SELECT Id FROM Account')) .chunkSize(200) .enqueue(); ``` The run takes one slot in the chain, so everything chained after it waits for the last page: ```apex Async.queueable(new PrepareJob()) .chunk(new AccountRecalcJob(), ChunkSource.of(accounts)) .chunkSize(200) .chain(new NotifyJob()) .enqueue(); ``` See the [Chunk API](/api/chunk) for ordering, priority, failure handling and the cursor limits. ## Error Handling Async Lib provides sophisticated error handling options: ```apex Async.queueable(new MyJob()) .continueOnJobEnqueueFail() // Don't fail if enqueue fails .continueOnJobExecuteFail() // Continue processing other jobs if this fails .rollbackOnJobExecuteFail() // Rollback any DML if job fails .enqueue(); ``` ## Using Finalizers Finalizers run after job completion and have access to success/failure context: ```apex public class MyJobFinalizer extends QueueableJob.Finalizer { public override void work() { Async.QueueableJobContext ctx = Async.getQueueableJobContext(); FinalizerContext finalizerCtx = ctx.finalizerCtx; if (finalizerCtx.getResult() == ParentJobResult.SUCCESS) { System.debug('Job completed successfully!'); } else { System.debug('Job failed: ' + finalizerCtx.getException().getMessage()); } } } // Attach finalizer within a job public class MyMainJob extends QueueableJob { public override void work() { // Do main work... // Attach finalizer Async.queueable(new MyJobFinalizer()) .attachFinalizer(); } } ``` ## Configuration Control job behavior using Custom Metadata (`QueueableJobSetting__mdt`): 1. Go to **Setup → Custom Metadata Types → QueueableJobSetting → Manage Records** 2. Create or edit settings: * **All**: Global settings for all jobs * **Specific Class Name**: Settings for specific job classes Available settings: * **QueueableJobName\_\_c**: The name of the QueueableJob. * **IsDisabled\_\_c**: Disable job execution * **CreateResult\_\_c**: Create `AsyncResult__c` records for tracking ## Async Result Records When enabled in `QueueableJobSetting__mdt` (**CreateResult\_\_c** = true), Async Lib automatically creates an `AsyncResult__c` record for every job in a chain — including jobs that were **skipped** — so you can see exactly what happened, and why. Query by **ChainId\_\_c** to see a whole chain run as a timeline. ### Key Fields * **ChainId\_\_c**: Correlation id shared by every job in the same chain run * **Status\_\_c**: Lifecycle outcome — `COMPLETED`, `FAILED`, `SKIPPED_DEPENDENCY`, `SKIPPED_CHAIN_STOPPED`, `SKIPPED_CHUNK_STOPPED`, `SKIPPED_EXPLICIT`, `SKIPPED_DISABLED`, `FRAMEWORK_ERROR` * **CustomJobId\_\_c**: Unique job ID generated by Async Lib * **SalesforceJobId\_\_c**: Underlying Salesforce job ID (Queueable, Batch, or Scheduled) * **ClassName\_\_c**: Apex class name of the job * **Result\_\_c**: Platform job result for jobs that ran (`SUCCESS`, `UNHANDLED_EXCEPTION`) * **ExceptionType\_\_c** / **ExceptionMessage\_\_c**: Failure detail when `Status__c` is `FAILED` * **SkipReason\_\_c**: Why a skipped job was skipped (e.g. *"Dependency 'extract' required SUCCESS but was FAILURE"*) * **DependsOnResult\_\_c**: Self-lookup to the result of the dependency that decided this job's fate, with **RequiredOutcome\_\_c** / **ActualOutcome\_\_c** * **RetryAttempts\_\_c**: How many retries the job went through * **RetryHistory\_\_c**: Per-attempt retry log These records are never deleted automatically. See [AsyncResult Cleanup](/explanations/asyncresult-cleanup) for the scheduled cleanup batch with separate retention for failed results and the rest. ## What's Next? Now that you understand the basics: 1. **[Standard Apex vs Async Lib](/introduction/standard-apex-vs-async-lib)** - See each plain-Apex async pattern next to its Async Lib equivalent. 2. **Explore the API** - Learn about all available methods and options: 1. **[Queueable API](/api/queueable.md)** - Detailed information on using Queueable jobs 2. **[Chunk API](/api/chunk.md)** - Paging a large data set across chained Queueables 3. **[Batchable API](/api/batchable.md)** - Detailed information on using Batchable jobs 4. **[Schedulable API](/api/schedulable.md)** - Detailed information on using Schedulable jobs 3. **Read the Blog Post** - Check out the detailed explanation: [Apex Queueable Processing Framework](https://blog.beyondthecloud.dev/blog/apex-queueable-processing-framework) 4. **[Initial Queueable Chain Schedulable Explanation](/explanations/initial-scheduled-queuable-batch-job.md)** - Learn why this job is important for framework to function properly. ## Quick Tips * **Job Naming**: Jobs get unique names with timestamps: `MyJob::2024-01-15T10:30:45.123Z::1` * **Custom Job IDs**: Every job gets a UUID for tracking independent of Salesforce Job IDs * **Priority Matters**: Lower numbers = higher priority. Finalizers always run first. * **Test Friendly**: Framework handles test context automatically * **Callouts Supported**: add `implements Database.AllowsCallouts` to any job --- --- url: https://async.beyondthecloud.dev/ai-usage.md --- # 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`](https://async.beyondthecloud.dev/llms.txt) and [`/llms-full.txt`](https://async.beyondthecloud.dev/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](/introduction/packaged-install). ## Entry points | Call | Returns | Use it for | | ---- | ------- | ---------- | | `Async.queueable(QueueableJob job)` | `QueueableBuilder` | one job, or the start of a chain | | `Async.queueable()` | `QueueableBuilder` | an empty builder to `.chain(job)` into; `enqueue()` with nothing added is a no-op | | `Async.chunk(ChunkJob job, ChunkSource source)` | `ChunkBuilder` | one job over many records, one page per transaction | | `Async.batchable(Database.Batchable job)` | `BatchableBuilder` | a standard batch, with scope and delay | | `Async.schedulable(Schedulable job)` | `SchedulableBuilder` | a standard schedulable, with cron helpers | | `Async.after(Result r)` / `Async.after(String customJobId)` / `Async.afterPrevious()` | `Async.Dependency` | the 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 resultIds)` | `Async.RequeueSummary` | replay failed jobs from their `AsyncResult__c` records | | `Async.getQueueableJobContext()` | `Async.QueueableJobContext` | inside a job: the current job, `QueueableContext`, `FinalizerContext` | | `Async.getCurrentQueueableChainState()` | `Async.QueueableChainState` | every job in the chain and what runs next | | `Async.getQueueableChainSchedulableId()` | `Id` | the scheduled job id when the chain started through the 50-job overflow path | | `Async.Backoff.fixed(m)` / `.exponential(m)` / `.exponentialWithJitter(m)` | `Backoff` | the 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. ### QueueableBuilder | Method | What 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)` | 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)` | 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: | Method | What 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 | Factory | Reads from | | ------- | ---------- | | `ChunkSource.of(List)` | records already in memory | | `ChunkSource.ofIds(Set)` | 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 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 | Method | What 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 | ### SchedulableBuilder and CronBuilder | Method | What it does | | ------ | ------------ | | `name(String)` | the scheduled job name, required | | `cronExpression(String)` / `cronExpression(CronBuilder)` / `cronExpression(List)` | when; a list schedules one job per expression | | `skipWhenAlreadyScheduled()` | no-op if a job with that name exists | | `schedule()` | returns `List` | `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 recordIds; public ImportJob(List recordIds) { this.recordIds = recordIds; } public override void work() { /* the job */ } } ``` | Member | On | When to override | | ------ | -- | ---------------- | | `void work()` | `QueueableJob` | always; the job body | | `void work(List page)` | `ChunkJob` | always; one page of the run | | `Boolean isRetryable(Exception ex)` | `QueueableJob` | veto a retry for a specific exception; default `true` | | `void onFinalFailure(Async.FailureContext ctx)` | `QueueableJob` | once, after the last attempt fails | | `QueueableJob cloneForDeepCopy()` | `QueueableJob` | packaged installs only; see `extras/BaseQueueableJob` | | `void resetBeforeRetry(Integer attempt)` | `implements Async.Retryable` | clear state before a retry; required by `retry(n)` unless `restoreStateOnRetry()` | | `void resetBeforeNextChunk(Integer pageNumber)` | `implements Async.ChunkResettable` | clear state before the next page; required by every `ChunkJob` unless `restoreStateOnNextChunk()` | | `onJobEnqueued` / `onJobSucceeded` / `onJobFailed` / `onRetryEnqueued` | `implements 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.JobSerializer` | packaged installs using `requeue()`; see `extras/AsyncJobSerializer` | 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 | Type | Fields | | ---- | ------ | | `Async.Result` | `salesforceJobId`, `customJobId`, `asyncType`, `job`, `queueableChainState` | | `Async.QueueableChainState` | `jobs`, `nextSalesforceJobId`, `nextCustomJobId`, `enqueueType` | | `Async.QueueableJobContext` | `currentJob`, `queueableCtx`, `finalizerCtx` | | `Async.JobContext` | `customJobId`, `className`, `salesforceJobId`, `chainId`, `priority`, `retryAttempt`, `info` | | `Async.FailureContext` | `retryOutcome`, `failure` (`type`, `message`, `stackTrace`), `customJobId`, `className`, `retryAttempt`, `maxRetries`, `retryHistory`, `nextAttemptDelayMinutes`, `info` | | `Async.RequeueSummary` | `requeued`, `skipReasonByResultId`, `enqueueResult` | | `Async.Outcome` | `SUCCESS`, `FAILURE`, `COMPLETED` | | `Async.RetryOutcome` | `NOT_CONFIGURED`, `NOT_RETRYABLE`, `EXHAUSTED` | | `Async.AsyncType` | `QUEUEABLE`, `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. | Field | Type | Effect | | ----- | ---- | ------ | | `IsDisabled__c` | Checkbox | the job is skipped with `SKIPPED_DISABLED` | | `CreateResult__c` | Checkbox | write an `AsyncResult__c` row for every outcome | | `MaxRetries__c` | Number | default retries, 0 to 10; only applied to jobs that declare how state resets | | `BackoffStrategy__c` | Text | `FIXED`, `EXPONENTIAL`, `EXPONENTIAL_JITTER` | | `BackoffBaseMinutes__c` | Number | base for the strategy | | `RetryableExceptions__c` | Text | comma-separated exception type names | | `LoggerClass__c` | Text | a `global` class implementing the lifecycle interfaces | | `StoreJobPayload__c` | Picklist `Yes`/`No` | store a snapshot for `requeue()`; `No` on a job beats `Yes` on `All` | | `JobSerializerClass__c` | Text | a `global` `Async.JobSerializer`, packaged installs only | In tests, inject them: `AsyncMock.jobSettings(new List{ ... })`. ## `AsyncResult__c` One row per job, written after its last attempt, when `CreateResult__c` is on or a payload is stored. Read access through the `AsyncResultAccess` permission set. | Field | Holds | | ----- | ----- | | `Status__c` | `COMPLETED`, `FAILED`, `SKIPPED_DEPENDENCY`, `SKIPPED_CHAIN_STOPPED`, `SKIPPED_CHUNK_STOPPED`, `SKIPPED_EXPLICIT`, `SKIPPED_DISABLED`, `FRAMEWORK_ERROR` | | `ClassName__c`, `CustomJobId__c`, `SalesforceJobId__c`, `ChainId__c` | identity | | `Result__c`, `ExceptionType__c`, `ExceptionMessage__c` | outcome | | `RetryAttempts__c`, `RetryHistory__c` | one line per attempt, plus configuration warnings | | `DependsOnResult__c`, `RequiredOutcome__c`, `ActualOutcome__c`, `SkipReason__c` | why a dependent job ran or was skipped | | `JobPayload__c`, `PayloadSize__c`, `RequeueStatus__c`, `RequeuedFrom__c` | requeue | Old rows do not delete themselves. Schedule `AsyncResultCleanupBatch` with `failedOlderThanDays(n)` and/or `othersOlderThanDays(n)`. ## `AsyncMock` | Call | What 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)` | 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 synced = new List(); 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 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 ``` ### 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()`. An empty `resetBeforeRetry` body is a valid answer. Same for every `ChunkJob` with `Async.ChunkResettable` or `restoreStateOnNextChunk()`. [Job State Between Runs](/explanations/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](/explanations/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](/explanations/expected-exceptions-in-debug-logs). * **`deepClone()` and `restoreStateOn*()` need a base class on a packaged install.** Copy `extras/BaseQueueableJob` and `BaseChunkJob`. Source deploys need nothing. [Deep Clone in Packages](/explanations/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](/explanations/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](/api/chunk). * **`delay()` and `asyncOptions()` are exclusive**, and `delay` tops out at 10 minutes. --- --- url: https://async.beyondthecloud.dev/introduction/standard-apex-vs-async-lib.md --- # Standard Apex vs Async Lib If you already know how to write a `Queueable`, a `Database.Batchable`, or a `Schedulable` in plain Apex, this page maps each of those to its Async Lib equivalent. Same concepts, less boilerplate, no "Too many queueable jobs" errors. Async Lib only wraps the **enqueue, chain, and schedule** parts. Your business logic stays where it always was, so things like `Database.Stateful`, `QueryLocator`, and finalizer context work exactly the same. ## At a glance | Standard Apex | Async Lib | | ---------------------------------------------- | --------------------------------------------------------- | | `implements Queueable` + `execute(context)` | `extends QueueableJob` + `work()` | | `System.enqueueJob(job)` | `Async.queueable(job).enqueue()` | | Enqueue next job inside `execute()` | `.chain(new NextJob())` | | 1 child queueable per transaction (hard limit) | Automatic overflow to a scheduled batch, no limit | | `System.attachFinalizer` + `implements Finalizer` | `extends QueueableJob.Finalizer` + `attachFinalizer()` | | Hand-rolled paging over a list or cursor | `Async.chunk(job, source).chunkSize(200).enqueue()` | | Track retries yourself to log a final failure | `override onFinalFailure(Async.FailureContext)` | | `Database.executeBatch(job, scope)` | `Async.batchable(job).scopeSize(scope).execute()` | | `implements Schedulable` + `System.schedule` | `Async.schedulable(job).cronExpression(...).schedule()` | | Hand-written cron string | `CronBuilder` fluent helpers | ## Queueable ### Defining a job In standard Apex you `implements Queueable` and put your logic in `execute(QueueableContext)`. With Async Lib you `extends QueueableJob` and override `work()`. The Salesforce `QueueableContext` is still available through the job context. **Standard Apex** ```apex public class AccountProcessorJob implements Queueable { private List accountIds; public AccountProcessorJob(List accountIds) { this.accountIds = accountIds; } public void execute(QueueableContext context) { List accounts = [SELECT Id, Name FROM Account WHERE Id IN :accountIds]; // ... process accounts ... update accounts; } } ``` **Async Lib** ```apex public class AccountProcessorJob extends QueueableJob { private List accountIds; public AccountProcessorJob(List accountIds) { this.accountIds = accountIds; } public override void work() { QueueableContext context = Async.getQueueableJobContext().queueableCtx; List accounts = [SELECT Id, Name FROM Account WHERE Id IN :accountIds]; // ... process accounts ... update accounts; } } ``` ### Enqueuing **Standard Apex** ```apex System.enqueueJob(new AccountProcessorJob(accountIds)); ``` **Async Lib** ```apex Async.queueable(new AccountProcessorJob(accountIds)) .priority(5) .enqueue(); ``` The builder adds options you'd otherwise hand-roll: `priority`, `delay`, `retry`, rollback/continue-on-failure, and more. See the [Queueable API](/api/queueable) for the full list. ### Chaining jobs Standard Apex lets you enqueue **one** child queueable from inside a running queueable. Go past that and you hit `System.AsyncException: Too many queueable jobs added to the queue: 2`. Async Lib chains as many jobs as you want and automatically overflows to a scheduled batch when the platform limit is reached. **Standard Apex** ```apex public class FirstJob implements Queueable { public void execute(QueueableContext context) { // ... work ... System.enqueueJob(new SecondJob()); // only one allowed per transaction } } ``` **Async Lib** ```apex Async.queueable(new FirstJob()) .chain(new SecondJob()) .chain(new ThirdJob()) .enqueue(); ``` Async Lib also adds [`dependsOn(...)`](/api/queueable#dependson) so a chained job can run only when an earlier one succeeded, failed, or finished. There is no standard-Apex equivalent. Failure behaves the same as standard Apex: if `FirstJob` throws, the jobs it chained inside `work()` are rolled back with the transaction, exactly as `System.enqueueJob` would be. Jobs that were already in the chain still run, which is where `dependsOn(...)` comes in. See [What a Failed Job Does to the Chain](/explanations/failures-and-the-chain). ### Processing a large data set In standard Apex you carry the position yourself: slice the list, enqueue the next job with the new offset, and remember where you were. **Standard Apex** ```apex public class RecalcJob implements Queueable { private List ids; private Integer position; public void execute(QueueableContext context) { List page = new List(); for (Integer i = position; i < Math.min(position + 200, ids.size()); i++) { page.add(ids[i]); } // ... work on page ... if (position + 200 < ids.size()) { System.enqueueJob(new RecalcJob(ids, position + 200)); // one chance, no tracking } } } ``` **Async Lib** ```apex public class RecalcJob extends ChunkJob { public override void work(List chunk) { // ... work on chunk ... } } Async.chunk(new RecalcJob(), ChunkSource.of(accounts)).chunkSize(200).enqueue(); ``` The framework carries the position, retries a page that throws, records an `AsyncResult__c` per page, and can page a `Database.Cursor` instead of a list when the set is too big to hold in memory. See the [Chunk API](/api/chunk). Your job keeps its own members across the whole run, so a running total or a map built on one page is still there on the next one. This is what `Database.Stateful` gives a batch, except you do not have to ask for it. ```apex public class RevenueRollupJob extends ChunkJob { private Map revenueByOwner = new Map(); private Integer processed = 0; public override void work(List chunk) { for (Opportunity opp : (List) chunk) { Decimal current = revenueByOwner.get(opp.OwnerId); revenueByOwner.put(opp.OwnerId, (current == null ? 0 : current) + opp.Amount); } processed += chunk.size(); } } ``` Each page starts from the state the previous page left behind, so `revenueByOwner` keeps growing and `processed` keeps counting for the length of the run. Keep the members serializable and keep them small: they travel with the job on every hop. ### Finalizers A finalizer runs after the job completes, whether it succeeded or threw. The shape is the same in both worlds; Async Lib just attaches it through the builder and exposes the `FinalizerContext` through the job context. **Standard Apex** ```apex public class CleanupFinalizer implements Finalizer { public void execute(FinalizerContext context) { if (context.getResult() == ParentJobResult.SUCCESS) { System.debug('Job succeeded'); } else { System.debug('Job failed: ' + context.getException().getMessage()); } } } public class MainJob implements Queueable { public void execute(QueueableContext context) { System.attachFinalizer(new CleanupFinalizer()); // ... work ... } } ``` **Async Lib** ```apex public class CleanupFinalizer extends QueueableJob.Finalizer { public override void work() { FinalizerContext context = Async.getQueueableJobContext().finalizerCtx; if (context.getResult() == ParentJobResult.SUCCESS) { System.debug('Job succeeded'); } else { System.debug('Job failed: ' + context.getException().getMessage()); } } } public class MainJob extends QueueableJob { public override void work() { Async.queueable(new CleanupFinalizer()).attachFinalizer(); // ... work ... } } ``` ### Reacting to a job that failed for good A finalizer tells you the job ended. It does not tell you whether a retry is still coming, and it cannot see a failure your own `catch` swallowed. Logging from one means logging on every attempt and hoping the last one wins. **Standard Apex** ```apex public class SyncJob implements Queueable { public void execute(QueueableContext context) { System.attachFinalizer(new LogFinalizer()); try { doWork(); } catch (Exception ex) { // Committing partial work means the finalizer sees SUCCESS and // context.getException() is null, so this catch is the only place // that knows anything failed. Retry state is yours to track too. insert new IntegrationLog__c(Message__c = ex.getMessage()); } } } ``` **Async Lib** ```apex public class SyncJob extends QueueableJob { public override void work() { doWork(); } public override void onFinalFailure(Async.FailureContext failureCtx) { insert new IntegrationLog__c( Message__c = failureCtx.failure?.message, StackTrace__c = failureCtx.failure?.stackTrace, Outcome__c = failureCtx.retryOutcome.name(), Attempts__c = failureCtx.retryAttempt, History__c = failureCtx.retryHistory ); } } ``` `onFinalFailure` fires once, only when the job will not run again, and it fires whether the exception propagated or was swallowed by [`continueOnJobExecuteFail`](/api/queueable#continueonjobexecutefail). See [`onFinalFailure`](/api/queueable#onfinalfailure). ## Batchable Your batch class does **not** change. It is a normal `Database.Batchable` with `start()`, `execute()`, and `finish()`. Async Lib only replaces the `Database.executeBatch(...)` call, adding scheduling and result tracking on top. **Standard Apex** ```apex Database.executeBatch(new AccountCleanupBatch(), 200); ``` **Async Lib** ```apex Async.batchable(new AccountCleanupBatch()) .scopeSize(200) .execute(); ``` ### Does `Database.Stateful` work the same? Yes. State is kept on **your** batch class, which Async Lib never touches. Implement `Database.Stateful` exactly as you do today. ```apex public class AccountCleanupBatch implements Database.Batchable, Database.Stateful { public Integer deletedCount = 0; public Database.QueryLocator start(Database.BatchableContext bc) { return Database.getQueryLocator('SELECT Id FROM Account WHERE IsActive__c = false'); } public void execute(Database.BatchableContext bc, List scope) { delete scope; deletedCount += scope.size(); // preserved across batches by Database.Stateful } public void finish(Database.BatchableContext bc) { System.debug('Deleted ' + deletedCount + ' accounts'); } } ``` ### How do I use `QueryLocator`? The same way. Return it from `start()` as usual (see the example above). Async Lib hands your job straight to `Database.executeBatch`, so the query locator, chunking, and 50-million-row limit all behave identically. ### Should I move my logic into `work()`? No. `work()` belongs to **queueable** jobs (`QueueableJob`). A batch keeps its `start()` / `execute()` / `finish()` methods. The only thing that moves is how you kick it off: `Async.batchable(job).execute()` instead of `Database.executeBatch(job)`. ## Schedulable Your `Schedulable` class is unchanged. Async Lib wraps `System.schedule`, builds the cron expression for you, and can skip scheduling when a job of the same name already exists (so you don't have to catch the "already scheduled" exception). **Standard Apex** ```apex public class NightlyJob implements Schedulable { public void execute(SchedulableContext context) { // ... work ... } } // daily at 02:00 — cron string written by hand System.schedule('Nightly Job', '0 0 2 * * ? *', new NightlyJob()); ``` **Async Lib** ```apex Async.schedulable(new NightlyJob()) .name('Nightly Job') .cronExpression('0 0 2 * * ? *') .skipWhenAlreadyScheduled() .schedule(); ``` ### Building the cron expression Instead of remembering cron field order, use `CronBuilder`. **Standard Apex** ```apex // every day at 02:00 System.schedule('Nightly Job', '0 0 2 * * ? *', new NightlyJob()); ``` **Async Lib** ```apex Async.schedulable(new NightlyJob()) .name('Nightly Job') .cronExpression(new CronBuilder().everyDay(2, 0)) .schedule(); ``` See the [Schedulable API](/api/schedulable#build---cron-expression) for the full set of `CronBuilder` helpers (`everyHour`, `everyXHours`, `everyMonth`, and so on). ### Scheduling a queueable or batch Standard Apex has no direct way to schedule a queueable. With Async Lib, any queueable or batch builder converts to a schedulable with `asSchedulable()`. ```apex Async.queueable(new AccountProcessorJob(accountIds)) .asSchedulable() .name('Hourly Account Processing') .cronExpression(new CronBuilder().everyHour(0)) .schedule(); ``` --- --- url: https://async.beyondthecloud.dev/introduction/installation.md --- # Installation Two ways to get Async Lib into an org. Pick one, then follow its guide. | | Unlocked package | Source deploy | | --- | --- | --- | | How | one install link | deploy button, `sf` CLI, or copy the source | | Namespace | `btcdev.` on every class, `btcdev__` on every field | none | | Upgrade | install the next version | redeploy from the next tag | | Extra setup | a few `extras` classes for features that copy or store a job | none | | Pick it when | you want a versioned, uninstallable unit and an upgrade path you do not maintain | you want to read, vendor or patch the code, or you cannot install packages | ## Install as Unlocked Package Install the latest version of Async Lib as an unlocked package: ``` https://login.salesforce.com/packaging/installPackage.apexp?p0=04tP6000003i1ujIAA ``` Then follow [Installing as a Package](/introduction/packaged-install): the prefix, the `extras` classes, what has to be `global`, and the permission set. ## Deploy the Source Or with the Salesforce CLI: ```bash git clone https://github.com/beyond-the-cloud-dev/async-lib.git cd async-lib sf project deploy start --source-dir force-app --target-org your-org ``` Then follow [Deploying the Source](/introduction/source-deploy): deploying a tag rather than `main`, production test levels, vendoring, and what a redeploy does to your `All` record. --- --- url: https://async.beyondthecloud.dev/introduction/packaged-install.md --- # Installing as a Package ## TL;DR Everything a **packaged** install needs that a source deploy does not, on one page. If you [deployed the source](/introduction/source-deploy) instead, skip this: there is no namespace boundary and everything already works. Most of the library needs nothing beyond the `btcdev.` prefix. A few features copy or store your job, and those need one class from [`extras/`](https://github.com/beyond-the-cloud-dev/async-lib/tree/main/extras/classes) in your namespace. That is the whole story, and the rest of this page is the details. ## The one rule behind all of it Async Lib runs inside its own `btcdev` namespace, and two platform behaviours follow from that: | Rule | Consequence | | ---- | ----------- | | `JSON.serialize` and `JSON.deserialize` refuse any object graph that crosses a namespace, in either direction | anything that copies or stores your job has to run in **your** code | | `Type.forName` from package code only resolves a subscriber class declared `global` | anything Async Lib reaches **by name** has to be `global` | Every item below is a consequence of one of those two. Neither depends on whether your job class is `public` or `global`; `global` gets a class past `Type.forName` and buys nothing else. ## Checklist ### 1. Install The current version and install link are on [Installation](/introduction/installation). ### 2. Use the prefix Every class, object and field carries it. | Source deploy | Packaged install | | ------------- | ---------------- | | `Async.queueable(...)` | `btcdev.Async.queueable(...)` | | `extends QueueableJob` | `extends btcdev.QueueableJob` | | `implements Async.Retryable` | `implements btcdev.Async.Retryable` | | `AsyncResult__c` | `btcdev__AsyncResult__c` | | `QueueableJobSetting__mdt.CreateResult__c` | `btcdev__QueueableJobSetting__mdt.btcdev__CreateResult__c` | The framework's error messages use the right prefix for the org they run in, so whatever a message tells you to write can be pasted as is. ### 3. Copy the `extras` classes you need The classes in [`extras/classes/`](https://github.com/beyond-the-cloud-dev/async-lib/tree/main/extras/classes) belong in your namespace, which is exactly why they cannot ship inside the package. Copy the ones for the features you use and rename them however you like. | Copy | When you use | Then | | ---- | ------------ | ---- | | `BaseQueueableJob` | `deepClone()`, `restoreStateOnRetry()` | `extends BaseQueueableJob` instead of `btcdev.QueueableJob` | | `BaseQueueableJob.Finalizer` | the same, on a finalizer | `extends BaseQueueableJob.Finalizer` | | `BaseChunkJob` | `restoreStateOnNextChunk()` | `extends BaseChunkJob` instead of `btcdev.ChunkJob` | | `AsyncJobSerializer` | `Async.requeue()` | register it, see step 4 | Callouts are a marker, not a base class, so `implements Database.AllowsCallouts` works on any of them. Why the base classes exist is in [Deep Clone in Packages](/explanations/deep-clone-in-packages), and the serializer in [Requeue](/explanations/requeue). ### 4. Declare `global` on anything you register by name Two fields on `QueueableJobSetting__mdt` name a class for Async Lib to construct. Both classes must be `global`, or the name resolves to nothing. Only the class needs it, the methods stay `public`. | Field | Implements | Ships in `extras`? | | ----- | ---------- | ------------------ | | `LoggerClass__c` | one or more of `btcdev.Async.OnJobEnqueued`, `OnJobSucceeded`, `OnJobFailed`, `OnRetryEnqueued` | no, that one is yours to write | | `JobSerializerClass__c` | `btcdev.Async.JobSerializer` | yes, `AsyncJobSerializer` | A name that does not resolve degrades rather than throws: no logging, or `NotSerializable` on the result, plus a warning in `RetryHistory__c` naming the field. It never stops a job. See [Configuration Safety](/explanations/configuration-safety). ### 5. Assign the permission set `btcdev__AsyncResultAccess` grants read on `btcdev__AsyncResult__c` and every field on it, for admins and reports. The framework writes those records in system context and does not need it itself. `JobPayload__c` is part of that set. If you turn `StoreJobPayload__c` on, whoever holds the set can read whatever data your jobs carried, so review it first. ## Feature by feature What each feature needs on a packaged install, and nothing more. | Feature | Needs | | ------- | ----- | | enqueue, chain, finalizers, `retry(n)`, `backoff`, `dependsOn` | the prefix | | `Async.Retryable`, `Async.ChunkResettable` | the prefix | | `deepClone()`, `restoreStateOnRetry()` | `BaseQueueableJob` | | `restoreStateOnNextChunk()` | `BaseChunkJob` | | `LoggerClass__c` | your logger class, declared `global` | | `Async.requeue()` | `AsyncJobSerializer`, copied and registered | ## Writing tests against the package `btcdev.AsyncMock` is part of the package and callable from your tests, so none of the settings above need real Custom Metadata: ```apex btcdev.AsyncMock.jobSettings( new List{ new btcdev__QueueableJobSetting__mdt( btcdev__QueueableJobName__c = 'All', btcdev__LoggerClass__c = 'MyAsyncLogger', btcdev__StoreJobPayload__c = 'Yes', btcdev__JobSerializerClass__c = 'AsyncJobSerializer' ) } ); ``` The prefixed constructor also works in a source deploy, so a test written this way does not need to change if you ever switch. ## Error messages that point back here | Message | You skipped | | ------- | ----------- | | `deepClone() failed ... Type cannot be serialized` | step 3, `BaseQueueableJob` | | `LoggerClass__c names "X", which could not be resolved` | step 4, `global` on the logger | | `StoreJobPayload__c is Yes for "X", but the job could not be serialized` | steps 3 and 4, `AsyncJobSerializer` | | `JobSerializerClass__c names "X", which is not a usable btcdev.Async.JobSerializer` | step 4, `global` or the interface | --- --- url: https://async.beyondthecloud.dev/introduction/source-deploy.md --- # Deploying the Source ## TL;DR The code lands in your org with no namespace, so there is no prefix, nothing to copy from `extras` and nothing to declare `global`. What you own instead is the upgrade path, because nothing tracks the version for you. If you installed the [unlocked package](/introduction/packaged-install), this page is not for you. ## What you get | | | | --- | --- | | Classes | `Async`, `QueueableJob`, `ChunkJob`, `AsyncMock`, the builders, and their tests | | Object | `AsyncResult__c` with every field and its page layout | | Custom Metadata | `QueueableJobSetting__mdt`, its layout, and one record named `All` | | Permission set | `AsyncResultAccess` | All of it `public`, in your default namespace: `Async.queueable(...)`, `extends QueueableJob`, `AsyncResult__c`. ## Three ways to deploy ### Deploy button The button deploys the latest release, `v3.0.0`, and the link is updated with every release the same way the package link is. To deploy an older release put its tag in the URL, and for whatever was merged last use `ref=main`: ``` https://githubsfdeploy.herokuapp.com?owner=beyond-the-cloud-dev&repo=async-lib&ref=main ``` Tags are on the [releases page](https://github.com/beyond-the-cloud-dev/async-lib/releases). ### Salesforce CLI ```bash git clone https://github.com/beyond-the-cloud-dev/async-lib.git cd async-lib git checkout v3.0.0 sf project deploy start --source-dir force-app --target-org your-org ``` Production, and any org that requires tests, needs `--test-level RunLocalTests`. That runs `AsyncTest`, about 300 tests, **and every test already in your org**. If an unrelated test of yours is failing, the deploy fails with it. For a sandbox you can use `--test-level RunSpecifiedTests --tests AsyncTest`; production still needs `RunLocalTests`. ### Vendor the source Copy `force-app/main/default/` into your own repository and deploy it with the rest of your code. From then on you own upgrades: diff the next tag against what you copied. The PMD suppressions in the classes travel with them, so a vendored copy passes the same static analysis it passes here. ## Upgrading Redeploy from the new tag, the same way you deployed the first time. Read the [release notes](https://github.com/beyond-the-cloud-dev/async-lib/releases) first: a major version means a breaking change, and the notes say what to change. ::: warning A redeploy replaces the `All` record `force-app/main/default/customMetadata/` holds one `QueueableJobSetting__mdt` record, `All`, with only `IsDisabled__c` and `QueueableJobName__c` set. A metadata deploy **replaces** a Custom Metadata record rather than merging it. Anything you set on `All` in the org that is not in that file, `CreateResult__c`, `MaxRetries__c`, `LoggerClass__c`, `StoreJobPayload__c`, is cleared. Leave that folder out when you upgrade: ```bash sf project deploy start --source-dir force-app/main/default/classes \ --source-dir force-app/main/default/objects \ --source-dir force-app/main/default/layouts \ --source-dir force-app/main/default/permissionsets \ --target-org your-org ``` Or note your `All` values first and put them back after. The per-job records you created yourself are not in the repository and are untouched either way. ::: ## After the deploy 1. Assign `AsyncResultAccess` to whoever should read `AsyncResult__c` in the UI or in reports. The framework writes those records in system context and does not need it. 2. Open `QueueableJobSetting__mdt` and decide what `All` should say. Nothing is on by default: no result rows, no retry, no logger, no payload storage. See [Configuration Safety](/explanations/configuration-safety) for what a wrong value does. 3. Write your first job. [Getting Started](/getting-started). ## Switching to the package later The code changes are mechanical, and [Installing as a Package](/introduction/packaged-install) is the checklist. In short: * every class reference gains `btcdev.`, every object and field gains `btcdev__` * any class registered in `LoggerClass__c` or `JobSerializerClass__c` becomes `global`. Keep them `public` until then, like any other class of yours; a source deploy has no boundary for `global` to cross * the features that copy or store a job start needing the [`extras`](https://github.com/beyond-the-cloud-dev/async-lib/tree/main/extras) base classes The data does not move. `AsyncResult__c` and `btcdev__AsyncResult__c` are different objects, so history stays on the old one and new jobs write to the new one. Uninstall the source classes only once nothing references them. --- --- url: https://async.beyondthecloud.dev/api/queueable.md --- # Queueable API Apex classes `QueueableBuilder.cls`, `QueueableManager.cls`, and `QueueableJob.cls`. New to async jobs? See [Standard Apex vs Async Lib](/introduction/standard-apex-vs-async-lib#queueable) for how this maps to a plain `Queueable`. For testing patterns and best practices, see [Testing Async Jobs](/explanations/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 accountIds; public AccountProcessorJob(List accountIds) { this.accountIds = accountIds; } public override void work() { List 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()); } } } ``` ::: info 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](/explanations/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](/explanations/deep-clone-in-packages). ```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. ::: tip 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**](#init) * [`queueable(QueueableJob job)`](#queueable) * [`queueable()`](#queueable-no-args) [**Build**](#build) * [`asyncOptions(AsyncOptions asyncOptions)`](#asyncoptions) * [`delay(Integer delay)`](#delay) * [`priority(Integer priority)`](#priority) * [`continueOnJobEnqueueFail()`](#continueonjobenqueuefail) * [`continueOnJobExecuteFail()`](#continueonjobexecutefail) * [`rollbackOnJobExecuteFail()`](#rollbackonjobexecutefail) * [`retry(Integer maxRetries)`](#retry) * [`backoff(Backoff backoff)`](#backoff) * [`retryOn(Type exceptionType)`](#retryon) * [`dependsOn(Async.Dependency dependency)`](#dependson) * [`deepClone()`](#deepclone) * [`restoreStateOnRetry()`](#restorestateonretry) * [`info(String key, String value)`](#info) * [`chain()`](#chain) * [`chain(QueueableJob job)`](#chain-next-job) * [`asSchedulable()`](#asschedulable) * [`mockId(String mockId)`](#mockid) [**Execute**](#execute) * [`enqueue()`](#enqueue) * [`attachFinalizer()`](#attachfinalizer) [**Context**](#context) * [`getQueueableJobContext()`](#getqueueablejobcontext) * [`getQueueableChainSchedulableId()`](#getqueueablechainschedulableid) * [`getCurrentQueueableChainState()`](#getcurrentqueueablechainstate) [**Chain control**](#chain-control) * [`stopChain()`](#stopchain) * [`skipJob(String customJobId)`](#skipjob) [**Override hooks**](#override-hooks) — methods you override on your `QueueableJob` subclass (not fluent builder calls) * [`isRetryable(Exception ex)`](#isretryable) * [`resetBeforeRetry(Integer attempt)`](#resetbeforeretry) — `Async.Retryable` * [`resetBeforeNextChunk(Integer pageNumber)`](#resetbeforenextchunk) — `Async.ChunkResettable` * [`onFinalFailure(Async.FailureContext failureCtx)`](#onfinalfailure) * [`onJobEnqueued` / `onJobSucceeded` / `onJobFailed` / `onRetryEnqueued`](#lifecycle-events) * \~~[`resetForRetry()`](#resetforretry)~~ ### 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) {#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(...)`](#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](/explanations/failures-and-the-chain). In both cases the job is still recorded as failed for [`dependsOn(...)`](#dependson) outcome checks, and any [`retry(...)`](#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()`](#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](/explanations/failures-and-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`. Passing a higher value to `retry(...)` throws. Configuring one on `QueueableJobSetting__mdt` clamps to the limit and records why, because a Custom Metadata mistake must never stop an org's jobs from running. See [Configuration Safety](/explanations/configuration-safety). 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](/explanations/failures-and-the-chain)). By default **every** exception is retried. Narrow retries to the failures worth re-running with the coarse type filter [`retryOn(...)`](#retryon) and/or the fine-grained [`isRetryable(Exception)`](#isretryable) override — when both are present, **both must pass** (see [`retryOn`](#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`). ::: tip 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()`](#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)`](#resetbeforeretry) via `Async.Retryable`, or [`restoreStateOnRetry()`](#restorestateonretry) to replay the job from the state it had at enqueue. See [Job State Between Runs](/explanations/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. | Strategy | Delay 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)`](#isretryable) 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 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: | Builder | Target | | ----------------------------- | -------------------------------------------------- | | `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: | Outcome | Runs 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](/explanations/job-cloning). ::: warning Package Usage When using Async Lib as a package (`btcdev` namespace), deep clone requires overriding `cloneForDeepCopy()` in your subclass. See [Deep Clone in Packages](/explanations/deep-clone-in-packages). ::: **Signature** ```apex QueueableBuilder deepClone(); ``` **Example** ```apex Async.queueable(new MyQueueableJob()) .deepClone(); ``` #### info Attaches arbitrary key/value metadata to the job. It arrives on every lifecycle context as `ctx.info`, and survives retries, chunk pages and serialization. Use it to route alerts by team, package or owner. See [Logging](/explanations/logging). **Signature** ```apex QueueableBuilder info(String key, String value); QueueableBuilder info(Map info); ``` **Example** ```apex Async.queueable(new ImportJob()) .info('team', 'platform') .enqueue(); ``` #### 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`](#resetbeforeretry). A job with `retry(n)` needs one of the two, or it throws at enqueue. ::: warning 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](/explanations/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](/api/chunk). 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](/api/schedulable) 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](/api/async-mock) 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:** | Property | Description | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `salesforceJobId` | Salesforce 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. | | `customJobId` | Unique Custom Job Id | | `asyncType` | `Async.AsyncType.QUEUEABLE` | | `job` | The `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. | | `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` | #### 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:** | 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. **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:** | 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 | ### Chain control [`dependsOn(...)`](#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**. ::: tip 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](/explanations/failures-and-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`](#dependson) the skipped job are skipped in turn. Throws if no job in the chain has that id. Like [`stopChain()`](#stopchain), a skip is undone if the attempt that called it did not commit. See [What a Failed Job Does to the Chain](/explanations/failures-and-the-chain). **Signature** ```apex void skipJob(String customJobId); ``` **Example** ```apex Async.skipJob(notificationsResult.customJobId); ``` #### requeue Rebuilds jobs from the payload stored on their `AsyncResult__c` records and runs them again as one chain. Needs `QueueableJobSetting__mdt.StoreJobPayload__c = Yes` before the original run, and a registered `Async.JobSerializer` on a packaged install. See [Requeue](/explanations/requeue). Every result that could not be replayed comes back with a reason. Throws when the call asks for more than 2,000,000 characters of payload. ::: warning Requeue replays data, not intent The payload was written by the class as it was and is rebuilt by the class as it is now. A renamed field arrives `null`; a field that changed meaning replays the old data under the new meaning, silently. If the fix changed the job's own fields, enqueue it fresh. See [Requeue](/explanations/requeue#what-is-stored). ::: **Signature** ```apex RequeueSummary requeue(Id resultId); RequeueSummary requeue(Set resultIds); ``` | `RequeueSummary` | Holds | | ---------------- | ----- | | `List requeued` | replayed | | `Map skipReasonByResultId` | the rest, and why | | `Async.Result enqueueResult` | the chain they run in, `null` when nothing was replayed | **Example** ```apex Async.RequeueSummary summary = Async.requeue(failedResultIds); for (Id skipped : summary.skipReasonByResultId.keySet()) { System.debug(skipped + ': ' + summary.skipReasonByResultId.get(skipped)); } ``` ### 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`](#retryon) and the [`retry`](#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`](#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()`](#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](/explanations/job-state-between-runs) for the full picture, including the chunk equivalent. #### resetBeforeNextChunk Declared by `Async.ChunkResettable`. The chunk equivalent of [`resetBeforeRetry`](#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()`](/api/chunk#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 pending = new List(); private Integer totalProcessed = 0; public override void work(List page) { ... } public void resetBeforeNextChunk(Integer pageNumber) { pending.clear(); // totalProcessed deliberately survives the whole run } } ``` #### Lifecycle events {#lifecycle-events} Four capability interfaces, implement only the ones you need. They work on a job directly, and on a class registered once in `QueueableJobSetting__mdt.LoggerClass__c` to cover the whole org. | Interface | Fires | Context | | --------- | ----- | ------- | | `Async.OnJobEnqueued` | a job is added to a chain | `Async.JobContext` | | `Async.OnJobSucceeded` | a job finished without failing | `Async.JobContext` | | `Async.OnJobFailed` | a job failed with no attempts left | `Async.FailureContext` | | `Async.OnRetryEnqueued` | an attempt failed and another is queued | `Async.FailureContext` | **Example** ```apex public class ImportJob extends QueueableJob implements Async.OnJobFailed { public override void work() { ... } public void onJobFailed(Async.FailureContext ctx) { Logger.error(ctx.className + ' failed: ' + ctx.failure.message); } } ``` A listener that throws never affects the job. Adding a fifth event later is a new interface, so existing listeners keep compiling. See [Logging](/explanations/logging) for org-wide registration, the `global` requirement and a Nebula adapter. #### ~~resetForRetry~~ {#resetforretry} ::: danger 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`](#resetbeforeretry) or [`restoreStateOnRetry()`](#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`](#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`** | Property | Type | Description | | -------------- | ------------------------- | ------------------------------------------------------------------------ | | `retryOutcome` | `Async.RetryOutcome` | How the retry question was settled. See below. | | `failure` | `QueueableJob.FailureInfo` | `type`, `message` and `stackTrace` of the exception that ended the job. | | `customJobId` | `String` | Correlates to `AsyncResult__c.CustomJobId__c`. | | `className` | `String` | The job class, namespace-qualified in a packaged install. | | `retryAttempt` | `Integer` | Attempts already made, `0` when the first run was the only one. | | `maxRetries` | `Integer` | The configured cap, `0` when no retry policy applied. | | `retryHistory` | `String` | One line per attempt with its exception type and message. | **`Async.RetryOutcome`** | Value | Meaning | | --------------------- | ------------------------------------------------------------------------- | | `NOT_CONFIGURED` | No `retry(n)` and no CMDT default, so the first failure was final. | | `NOT_RETRYABLE` | [`retryOn`](#retryon) excluded the type, or [`isRetryable`](#isretryable) returned `false`. | | `EXHAUSTED` | Retried 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: | Value | What actually happened | Usual reaction | | ---------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | `NOT_CONFIGURED` | Ran 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_RETRYABLE` | Your 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. | | `EXHAUSTED` | It 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. --- --- url: https://async.beyondthecloud.dev/api/chunk.md --- # Chunk API Apex classes `ChunkBuilder.cls`, `ChunkJob.cls`, and `ChunkSource.cls`. Process a large data set in tuned pages across chained Queueables, with the same per-job tracking and retry/backoff as a normal `QueueableJob`. Reach for this when you need to process thousands of records in the background but do not want a `Database.Batchable` (heavier, separate lifecycle) or one job per record. Each page runs as its own tracked job in the chain. When a page settles, the framework appends the next page and drops the settled one, so only the current position moves forward and a huge run never materializes a giant job list. A chunk run is an ordinary chain member. Jobs chained before it run first, jobs chained after it wait for the last page, and `dependsOn(...)` resolves against the outcome of the whole run. See [Placing a run in a chain](#placing-a-run-in-a-chain). **Common ChunkJob class example:** Extend `ChunkJob` and put your per-page logic in `work(List chunk)`. ```apex public class AccountRecalcJob extends ChunkJob { public override void work(List chunk) { List accounts = (List) chunk; for (Account acc : accounts) { acc.Description = 'Recalculated'; } update accounts; } } ``` ```apex Async.Result result = Async.chunk(new AccountRecalcJob(), ChunkSource.of(records)) .chunkSize(50) .priority(5) .retry(2) .enqueue(); ``` ::: warning deepClone is not supported for a ChunkJob Pages share one source and are isolated by serialization at each enqueue, and a `Database.Cursor` source cannot be JSON-serialized. `Async.chunk(...)` throws if the job has `deepClone` set. Build the job fully before handing it to `Async.chunk(...)` (do not mutate its members afterward). ::: **Carrying state between chunks:** Your job keeps its own members for the length of the run. A page starts from the state the previous page left behind, so a running total or a map built on one page is still there on the next one. This is what `Database.Stateful` gives a batch, without asking for it. ```apex public class RevenueRollupJob extends ChunkJob { private Map revenueByOwner = new Map(); private Integer processed = 0; public override void work(List chunk) { for (Opportunity opp : (List) chunk) { Decimal current = revenueByOwner.get(opp.OwnerId); revenueByOwner.put(opp.OwnerId, (current == null ? 0 : current) + opp.Amount); } processed += chunk.size(); } } ``` Keep those members serializable and small: they travel with the job on every hop. A page that fails and retries starts from the state that attempt began with, so keep the accumulation idempotent if you retry. **Working by id:** When the work only needs ids, feed the run `ChunkSource.ofIds(...)` and read them straight off the page. Ids are 18 characters each, so they travel far more cheaply across hops than whole records, and re-querying inside `work(...)` means a long run acts on current data rather than a snapshot taken before the first page. ```apex public class CreateWelcomeTasksJob extends ChunkJob { public override void work(List chunk) { List tasks = new List(); for (Id accountId : new Map(chunk).keySet()) { tasks.add(new Task(WhatId = accountId, Subject = 'Welcome call')); } insert tasks; } } ``` ```apex Async.chunk(new CreateWelcomeTasksJob(), ChunkSource.ofIds(accountIds)) .chunkSize(200) .enqueue(); ``` `ofIds` yields id-only shells, so there are no other fields to read. If your job needs fields, query them inside `work(...)` or use a source that selects them. **Large set via SOQL cursor example:** For sets too large to hold in memory, hand the framework a `Database.Cursor` (or a SOQL string it opens for you). The cursor is never fully materialized; each page fetches its slice. ```apex Async.chunk(new AccountRecalcJob(), ChunkSource.query('SELECT Id FROM Account WHERE ...')) .chunkSize(200) .enqueue(); ``` ## Placing a run in a chain The run holds one slot in the chain. Every page of it finishes before the next chain member starts, however many pages the source yields. ```apex Async.queueable(new PrepareJob()) .chunk(new AccountRecalcJob(), ChunkSource.query('SELECT Id FROM Account WHERE ...')) .chunkSize(200) .chain(new NotifyJob()) .dependsOn(Async.afterPrevious().succeeded()) .enqueue(); ``` `PrepareJob` runs, then every page of the run, then `NotifyJob`. Runs chain to each other the same way, for multi-stage pipelines: ```apex Async.chunk(new StageOneJob(), firstSource) .chunkSize(200) .chunk(new StageTwoJob(), secondSource) .chunkSize(100) .enqueue(); ``` `dependsOn(...)` against a chunk run reads the outcome of the run as a whole: | Required outcome | Runs when | | ---------------- | -------------------------------------- | | `.succeeded()` | every page finished without failing | | `.failed()` | at least one page failed | | `.finished()` | the run ended, whatever the outcome | Priority still decides who goes first. A job added while the run is in flight with a higher priority runs before the next page, then the run resumes. A job of equal or lower priority (or no priority at all) waits for the whole run. ```apex public class AccountRecalcJob extends ChunkJob { public override void work(List chunk) { // ... Async.queueable(new UrgentJob()).priority(1).chain(); // jumps the remaining pages } } ``` ## Failure handling Two independent levels: * **Per chunk (retry).** A page that throws retries per `.retry(...)` / `.backoff(...)`, exactly like a `QueueableJob`. Each terminal page records an `AsyncResult__c` row (`COMPLETED` or `FAILED`). * **Across the run.** By default the run keeps processing the remaining chunks even if one fails (best-effort, `Batchable`-like). Call `.stopRemainingChunksOnFailure()` to stop the run when a chunk exhausts its retries. A run that ends early records one summary `AsyncResult__c` naming the page it stopped at and how much work was left, so you can tell a completed run from a truncated one: | Ended by | Status | | --------------------------------- | ----------------------- | | `.stopRemainingChunksOnFailure()` | `SKIPPED_CHUNK_STOPPED` | | `Async.stopChain()` | `SKIPPED_CHAIN_STOPPED` | `Async.stopChain()` stops the whole chain wherever it is, including mid-run. ## Callouts Mark the job with `Database.AllowsCallouts` and every page can call out: ```apex public class SyncChunk extends ChunkJob implements Database.AllowsCallouts, Async.ChunkResettable { public override void work(List page) { HttpResponse response = new Http().send(request); } public void resetBeforeNextChunk(Integer pageNumber) { } } ``` `Database.AllowsCallouts` is the standard Salesforce marker interface, the same one you put on any `Queueable` that calls out. Nothing Async Lib specific. It is the one way to declare callouts across every job type. See [Callouts](/api/queueable#callouts). Each page is its own transaction, so each page gets its own callout limit. The capability survives paging and retries because every page and every retry is a clone of the same concrete class, and it survives `restoreStateOnNextChunk()` for the same reason. Watch the usual ordering rule inside a page: a callout after DML in the same transaction throws `You have uncommitted work pending`. Call out first, then do your DML. ## Cursor governor caps `ChunkSource.cursor(...)` / `ChunkSource.query(...)` open a SOQL cursor. Salesforce enforces cursor limits you should weigh against a `Database.Batchable`: | Limit | Value | | ---------------------------------- | ---------------------------- | | Rows per cursor | 50 million | | Records per `fetch()` call | 2,000 (caps `chunkSize` on a cursor source) | | `fetch()` calls per transaction | 100 (a page uses one) | | Cursor instances per org per day | 10,000 | | Cursor lifespan | 2 days | ::: warning A throttled run can outlive its cursor A cursor expires 2 days after it is opened. A run that pages a large set with `.delayBetweenChunks(...)`, or one that sits behind a long queue, can still have pages left when the cursor dies. Size `chunkSize` and the delay so the run finishes inside 2 days, or use a `Database.Batchable` for very long runs. ::: `ChunkSource.of(...)` / `ChunkSource.ofIds(...)` hold records in memory instead, so they are bounded by heap, not cursor limits. Prefer them for smaller sets and for tests. ## Security `ChunkSource.query(String soql)` runs the SOQL you pass, so you own its safety. Use a bind overload rather than concatenating user input. The framework hands each page to your `work(...)` as queried; it does not strip fields. ```apex ChunkSource.query( 'SELECT Id FROM Account WHERE Industry = :industry', new Map{ 'industry' => userInput } ); ``` ### Access level **A cursor opened by `ChunkSource` runs in `AccessLevel.SYSTEM_MODE` by default.** Field-level security, object permissions and sharing rules are not applied. Chunk runs are usually administrative work over data the running user may not personally see, so this matches both `Database.getCursor` on the current API version and what most runs actually want. It is still a deliberate choice you should be aware of. Pass an `AccessLevel` to change it: ```apex ChunkSource.query('SELECT Id FROM Account WHERE ...', AccessLevel.USER_MODE); ChunkSource.query( 'SELECT Id FROM Account WHERE Industry = :industry', new Map{ 'industry' => userInput }, AccessLevel.USER_MODE ); ``` `WITH USER_MODE` inside the query string works too and wins over the parameter. ::: warning Why this is passed explicitly The framework always passes an `AccessLevel` rather than relying on the platform default, because that default is not stable. Per the Apex Developer Guide, "in API version 67.0 and later, Apex runs in user context by default", where API 66.0 and earlier default to system mode. Had `ChunkSource` relied on the implicit default, bumping the API version would have silently switched every existing run to user mode, and runs would quietly return fewer rows with no error. Passing it explicitly keeps behaviour identical across that bump. ::: ## Testing a run A chunk page is an ordinary job in the chain, so everything in [AsyncMock](/api/async-mock) works on a run exactly as it does on a queueable. What a chunk run adds is the **source**, and that needs no mock: you pass it to `Async.chunk(...)` yourself, so a test swaps it at the call site. | To test | Do this | | ------------------------------ | ----------------------------------------------------- | | Your `work(...)` logic | Call `work(records)` directly. No enqueue, no chain. | | Paging, state, failure policy | `ChunkSource.of(records)` in place of the real source | | Your SOQL string | Insert data and use the real `ChunkSource.query(...)` | | Paging at scale, broken fetch | Your own `ChunkSource` subclass | | A page's `QueueableContext` | `.mockId(...)` plus `AsyncMock.whenQueueable(...)` | | A specific page failing | `.mockId(...)` plus `AsyncMock.whenQueueable(...).thenThrow(...)` | | A page finalizer's error path | `.mockId(...)` on the finalizer plus `AsyncMock.whenFinalizer(...).thenThrow(...)` | Every page consumes one entry from the mock queue, so mixing `thenReturn` and `thenThrow` picks which page fails: ```apex AsyncMock.whenQueueable('recalc-run') .thenReturn(new AsyncMock.MockQueueableContext()) // page 1 succeeds .thenThrow(new CalloutException('boom')) // page 2 fails .thenReturn(new AsyncMock.MockQueueableContext()); // page 3 succeeds Async.chunk(new AccountRecalcJob(), ChunkSource.of(records)) .chunkSize(2) .mockId('recalc-run') .stopRemainingChunksOnFailure() .enqueue(); ``` A cursor run is testable as-is: `Database.getCursor(...)` works inside a test against data the test inserted. Prefer that whenever the query itself is the thing you want to cover, because an in-memory source never executes your SOQL. Design for the swap. A service that hardcodes its source cannot be told to use another one: ```apex // Hard to test: the source is welded in public static void recalcAll() { Async.chunk(new AccountRecalcJob(), ChunkSource.query('SELECT Id FROM Account')).enqueue(); } // Easy to test: the caller decides public static void recalcAll(ChunkSource source) { Async.chunk(new AccountRecalcJob(), source).enqueue(); } ``` Cursor expiry, the daily cursor allocation, and the 2,000 record fetch ceiling are platform behaviours. They cannot be reproduced in a test, and faking them would test the fake rather than the framework. See [Testing Async Jobs](/explanations/testing-async-jobs) for worked examples. ## Methods ### INIT #### chunk Constructs a new `ChunkBuilder` for the given job and record source. The source is mandatory. **Signature** ```apex ChunkBuilder Async.chunk(ChunkJob job, ChunkSource source); ChunkBuilder QueueableBuilder.chunk(ChunkJob job, ChunkSource source); ``` Use `Async.chunk(...)` to start a chain with the run, and `.chunk(...)` on a `QueueableBuilder` to put a run after jobs you already chained. **Example** ```apex Async.chunk(new AccountRecalcJob(), ChunkSource.of(records)); Async.queueable(new PrepareJob()).chunk(new AccountRecalcJob(), ChunkSource.of(records)); ``` ### Source `ChunkSource` is the pluggable seam between the framework and your data. Use a factory, or extend `ChunkSource` for a fully custom source. | Factory | Source | | -------------------------------------------------- | -------------------------------------- | | `ChunkSource.of(List)` | in-memory records (also the test fake) | | `ChunkSource.ofIds(Set)` | id-only source | | `ChunkSource.cursor(Database.Cursor)` | wraps an existing SOQL cursor | | `ChunkSource.query(String soql)` | opens a cursor over the query | | `ChunkSource.query(String soql, AccessLevel accessLevel)` | same, with an explicit access level | | `ChunkSource.query(String soql, Map binds)` | same, with bind variables | | `ChunkSource.query(String soql, Map binds, AccessLevel accessLevel)` | binds plus access level | The two abstract methods mirror `Database.Cursor`, so wrapping a cursor is a straight delegate and a custom source only has to answer the same two questions. `maxChunkSize()` is how a source declares its own page ceiling, if it has one. **Signature** ```apex abstract Integer getNumRecords(); abstract List fetch(Integer position, Integer count); virtual Integer maxChunkSize(); // null means no ceiling ``` ### Build #### chunkSize Sets how many records each page processes. Must be positive, and no larger than the source's `maxChunkSize()` when it declares one. Defaults to `200`. A cursor source caps a page at 2,000 records, the most `Database.Cursor.fetch()` returns. An in-memory source has no ceiling; what bounds a page there is your `work(...)` body, so keep DML rows, heap and CPU in mind before going large. **Signature** ```apex ChunkBuilder chunkSize(Integer chunkSize); ``` **Example** ```apex Async.chunk(new AccountRecalcJob(), ChunkSource.of(records)).chunkSize(50); ``` #### stopRemainingChunksOnFailure Stops the run when a chunk exhausts its retries. Off by default (remaining chunks still run). **Signature** ```apex ChunkBuilder stopRemainingChunksOnFailure(); ``` #### dependsOn Runs the chunk run only when an earlier chain member had the required outcome. Same semantics as [Queueable dependsOn](/api/queueable#dependson). **Signature** ```apex ChunkBuilder dependsOn(Async.Dependency dependency); ``` **Example** ```apex Async.queueable(new PrepareJob()) .chunk(new AccountRecalcJob(), ChunkSource.of(records)) .dependsOn(Async.afterPrevious().succeeded()) .enqueue(); ``` #### priority Sets the chunk job priority. **Signature** ```apex ChunkBuilder priority(Integer priority); ``` #### delay Sets a one-time delay in minutes before the first page runs. It does not repeat on later pages. **Signature** ```apex ChunkBuilder delay(Integer delay); ``` #### delayBetweenChunks Throttles the run by waiting this many minutes before each page after the first. Use it to spread DML/callout load or stay under per-hour async limits. Salesforce caps enqueue delay at 10 minutes. **Signature** ```apex ChunkBuilder delayBetweenChunks(Integer minutes); ``` #### retry Sets the maximum retry attempts per chunk. Must be `0..10`. **Signature** ```apex ChunkBuilder retry(Integer maxRetries); ``` #### restoreStateOnRetry Replays every retry of a page from the state the job had when the run was enqueued. Alternative to implementing [`Async.Retryable`](/api/queueable#resetbeforeretry). A chunk run with `retry(n)` needs one of the two, or it throws at enqueue. The page position is carried forward, so a restored retry re-runs the page it failed on, not the first page. **Signature** ```apex ChunkBuilder restoreStateOnRetry(); ``` #### restoreStateOnNextChunk Replays every page from the state the job had when the run was enqueued, so no page ever sees what the previous one left behind. Alternative to implementing [`Async.ChunkResettable`](/api/queueable#resetbeforenextchunk). Every chunk run needs one of the two, or it throws at enqueue. Only fields you declared are restored. The chunk position, page count and source carry forward, which is also why the `ChunkSource` itself is never serialized: a `Database.Cursor` source works here exactly like an in-memory one. ::: warning 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 `BaseChunkJob`, which does it for you. See [Deep Clone in Packages](/explanations/deep-clone-in-packages). `resetBeforeNextChunk()` needs neither. ::: **Signature** ```apex ChunkBuilder restoreStateOnNextChunk(); ``` **Example** ```apex Async.chunk(new ImportChunk(), ChunkSource.query('SELECT Id FROM Account')) .chunkSize(200) .restoreStateOnNextChunk() .enqueue(); ``` #### backoff Sets the retry backoff strategy. See the [Queueable backoff table](/api/queueable#backoff). **Signature** ```apex ChunkBuilder backoff(Backoff backoff); ``` #### retryOn Restricts retries to the given exception type(s). **Signature** ```apex ChunkBuilder retryOn(Type exceptionType); ChunkBuilder retryOn(List exceptionTypes); ``` #### mockId Sets a mock id for the chunk job. See [AsyncMock](/api/async-mock). **Signature** ```apex ChunkBuilder mockId(String mockId); ``` #### keepChunkPages By default a settled page is pruned from the chain once its result is recorded, so the run stays flat at any scale. Call this to retain settled pages in `queueableChainState.jobs` instead. Off by default; not for large runs, since kept pages grow the serialized chain state. This only affects what you can read from inside the run. Every page writes its own `AsyncResult__c` either way, so you do not need it to see the results of a run. **Signature** ```apex ChunkBuilder keepChunkPages(); ``` ### Execute #### chain Chains a job to run after the last page of the run and returns a `QueueableBuilder` for it, so the rest of the chain reads exactly like a normal one. **Signature** ```apex QueueableBuilder chain(QueueableJob nextJob); Async.Result chain(); ``` **Example** ```apex Async.chunk(new AccountRecalcJob(), ChunkSource.of(records)) .chunkSize(50) .chain(new NotifyJob()) .enqueue(); ``` #### chunk next run Chains a second run after the current one and returns its `ChunkBuilder`, so multi-stage pipelines stay fluent. Every page of the first run completes before the first page of the second one starts. **Signature** ```apex ChunkBuilder chunk(ChunkJob nextChunkJob, ChunkSource nextSource); ``` **Example** ```apex Async.chunk(new StageOneJob(), ChunkSource.query('SELECT Id FROM Account WHERE ...')) .chunkSize(200) .chunk(new StageTwoJob(), ChunkSource.query('SELECT Id FROM Contact WHERE ...')) .chunkSize(100) .enqueue(); ``` `dependsOn(Async.afterPrevious())` on the second run resolves against the first run's **run-level** outcome, so `succeeded()` means every page of stage one passed. ```apex Async.chunk(new StageOneJob(), source) .chunkSize(200) .chunk(new StageTwoJob(), otherSource) .dependsOn(Async.afterPrevious().succeeded()) .enqueue(); ``` Each source is evaluated when its builder is created, not when its run starts. If stage two must query rows that stage one produces, build it inside stage one's `work(...)` instead, or use a plain `chain(...)` job that enqueues it. #### enqueue Enqueues the run and returns the `Async.Result` for the first page. Enqueuing an empty source is a safe no-op: the run itself is skipped, and any jobs already chained still run. **Signature** ```apex Async.Result enqueue(); ``` **Example** ```apex Async.Result result = Async.chunk(new AccountRecalcJob(), ChunkSource.of(records)) .chunkSize(50) .enqueue(); ``` --- --- url: https://async.beyondthecloud.dev/api/batchable.md --- # Batchable API Apex classes `BatchableBuilder.cls` and `BatchableManager.cls`. New to async jobs? See [Standard Apex vs Async Lib](/introduction/standard-apex-vs-async-lib#batchable) for how this maps to a plain `Database.executeBatch`. **Common BatchJob class example:** Your batch class is a normal `Database.Batchable`. Async Lib does not change it, so `Database.Stateful`, `Database.QueryLocator`, and the `start` / `execute` / `finish` methods all work as usual. ```apex public class AccountCleanupBatch implements Database.Batchable, Database.Stateful { public Integer deletedCount = 0; public Database.QueryLocator start(Database.BatchableContext bc) { return Database.getQueryLocator('SELECT Id FROM Account WHERE IsActive__c = false'); } public void execute(Database.BatchableContext bc, List scope) { delete scope; deletedCount += scope.size(); } public void finish(Database.BatchableContext bc) { System.debug('Deleted ' + deletedCount + ' accounts'); } } ``` **Common Batchable example:** ```apex Async.Result result = Async.batchable(new AccountCleanupBatch()) .scopeSize(100) .execute(); System.debug('Batch job enqueued: ' + result.salesforceJobId); ``` ::: tip Ready-made cleanup batch Async Lib ships `AsyncResultCleanupBatch` for deleting old `AsyncResult__c` records, with separate retention for failed results and the rest. See [AsyncResult Cleanup](/explanations/asyncresult-cleanup). ::: ## Methods The following are methods for using Async with Batchable jobs: [**INIT**](#init) * [`batchable(Database.Batchable job)`](#batchable) [**Build**](#build) * [`scopeSize(Integer size)`](#scopesize) * [`minutesFromNow(Integer minutes)`](#minutesfromnow) * [`asSchedulable()`](#asschedulable) [**Execute**](#execute) * [`execute()`](#execute-1) ### INIT #### batchable Constructs a new BatchableBuilder instance with the specified batchable job. **Signature** ```apex Async batchable(Database.Batchable job); ``` **Example** ```apex Async.batchable(new MyBatchJob()); ``` ### Build #### scopeSize Allows setting the scope size for the batch job. **Signature** ```apex BatchableBuilder scopeSize(Integer size); ``` **Example** ```apex Async.batchable(new MyBatchJob()) .scopeSize(100); ``` #### minutesFromNow Allows scheduling the batch job to run after a specified number of minutes. **Signature** ```apex BatchableBuilder minutesFromNow(Integer minutes); ``` **Example** ```apex Async.batchable(new MyBatchJob()) .minutesFromNow(10); ``` #### asSchedulable Converts the batch builder to a schedulable builder for cron-based scheduling. See [Schedulable API](/api/schedulable) for scheduling options. **Signature** ```apex SchedulableBuilder asSchedulable(); ``` **Example** ```apex Async.batchable(new MyBatchJob()) .asSchedulable(); ``` ### Execute #### execute Executes the batch job with the configured options. Returns an Async.Result. **Signature** ```apex Async.Result execute(); ``` **Example** ```apex Async.Result result = Async.batchable(new MyBatchJob()) .scopeSize(100) .execute(); ``` Returns `result.salesforceJobId` containing the Salesforce Job Id. --- --- url: https://async.beyondthecloud.dev/api/schedulable.md --- # Schedulable API Apex classes `SchedulableBuilder.cls`, `SchedulableManager.cls`, and `CronBuilder.cls`. New to async jobs? See [Standard Apex vs Async Lib](/introduction/standard-apex-vs-async-lib#schedulable) for how this maps to a plain `System.schedule`. **Common SchedulableJob class example:** Your class is a normal `Schedulable`. Async Lib only replaces the `System.schedule(...)` call, building the cron expression and skipping the job when one with the same name is already scheduled. ```apex public class AccountReportJob implements Schedulable { public void execute(SchedulableContext context) { // ... work ... } } ``` **Common Schedulable example:** ```apex List results = Async.schedulable(new AccountReportJob()) .name('Daily Processing Job') .cronExpression('0 0 2 * * ? *') .skipWhenAlreadyScheduled() .schedule(); System.debug('Scheduled job results: ' + results); ``` ## Methods The following are methods for using Async with Schedulable jobs: [**INIT**](#init) * [`schedulable(Schedulable scheduleJob)`](#schedulable) [**Build - Schedulable**](#build---schedulable) * [`name(String name)`](#name) * [`cronExpression(String cronExpression)`](#cronexpression) * [`cronExpression(CronBuilder builder)`](#cronexpression-1) * [`cronExpression(List builders)`](#cronexpression-2) * [`skipWhenAlreadyScheduled()`](#skipwhenalreadyscheduled) [**Build - Cron Expression**](#build---cron-expression) * [`second(String second)`](#second) * [`minute(String minute)`](#minute) * [`hour(String hour)`](#hour) * [`dayOfMonth(String dayOfMonth)`](#dayofmonth) * [`month(String month)`](#month) * [`dayOfWeek(String dayOfWeek)`](#dayofweek) * [`optionalYear(String optionalYear)`](#optionalyear) * [`buildForEveryXMinutes(Integer everyXMinutes)`](#buildforeveryxminutes) * [`everyHour(Integer minute)`](#everyhour) * [`everyXHours(Integer everyXHours, Integer minute)`](#everyxhours) * [`everyDay(Integer hour, Integer minute)`](#everyday) * [`everyXDays(Integer everyXDays, Integer hour, Integer minute)`](#everyxdays) * [`everyMonth(Integer day, Integer hour, Integer minute)`](#everymonth) * [`everyXMonths(Integer everyXMonths, Integer dayOfMonth, Integer hour, Integer minute)`](#everyxmonths) * [`getCronExpression()`](#getcronexpression) [**Schedule**](#schedule) * [`schedule()`](#schedule) ### INIT #### schedulable Constructs a new SchedulableBuilder instance with the specified schedulable job. **Signature** ```apex Async schedulable(Schedulable scheduleJob); ``` **Strict Example** ```apex Async.schedulable(new MySchedulableJob()); ``` **Batchable Conversion Example** ```apex Async.batchable(new MyBatchJob()) .asSchedulable(); ``` **Queueable Conversion Example** ```apex Async.queueable(new MyQueueableJob()) .asSchedulable(); ``` ### Build - Schedulable #### name Sets the name for the scheduled job. This is required for scheduling. **Signature** ```apex SchedulableBuilder name(String name); ``` **Example** ```apex Async.schedulable(new MySchedulableJob()) .name('Daily Cleanup Job'); ``` #### cronExpression string Sets a cron expression for scheduling the job. Can be called multiple times to schedule at different intervals. **Signature** ```apex SchedulableBuilder cronExpression(String cronExpression); ``` **Example** ```apex Async.schedulable(new MySchedulableJob()) .name('Hourly Job') .cronExpression('0 0 * * * ? *'); ``` #### cronExpression builder Sets a cron expression using a CronBuilder for more advanced scheduling configuration. **Signature** ```apex SchedulableBuilder cronExpression(CronBuilder builder); ``` **Example** ```apex CronBuilder cron = new CronBuilder().everyHour(1); Async.schedulable(new MySchedulableJob()) .name('Nightly Job') .cronExpression(cron); ``` #### cronExpression multiple builders Sets multiple cron expressions using a list of CronBuilder instances for complex scheduling scenarios. **Signature** ```apex SchedulableBuilder cronExpression(List builders); ``` **Example** ```apex List crons = new List{ new CronBuilder().everyHour(0), new CronBuilder().everyDay(0, 0) }; Async.schedulable(new MySchedulableJob()) .name('Business Hours Job') .cronExpression(crons); ``` #### skipWhenAlreadyScheduled If set, the job will not be scheduled if it is already scheduled with the same name. This prevents throwing the `System.AsyncException`. **Signature** ```apex SchedulableBuilder skipWhenAlreadyScheduled(); ``` **Example** ```apex Async.schedulable(new MySchedulableJob()) .skipWhenAlreadyScheduled(); ``` ### Build - Cron Expression #### second Sets the second value for the cron expression (0-59). **Signature** ```apex CronBuilder second(String second); ``` **Example** ```apex CronBuilder cron = new CronBuilder() .second('30') .minute('0') .hour('12'); ``` #### minute Sets the minute value for the cron expression (0-59). **Signature** ```apex CronBuilder minute(String minute); ``` **Example** ```apex CronBuilder cron = new CronBuilder() .minute('15') .hour('10'); ``` #### hour Sets the hour value for the cron expression (0-23). **Signature** ```apex CronBuilder hour(String hour); ``` **Example** ```apex CronBuilder cron = new CronBuilder() .hour('14') .minute('0'); ``` #### dayOfMonth Sets the day of month value for the cron expression (1-31). **Signature** ```apex CronBuilder dayOfMonth(String dayOfMonth); ``` **Example** ```apex CronBuilder cron = new CronBuilder() .dayOfMonth('15') .hour('9'); ``` #### month Sets the month value for the cron expression (1-12). **Signature** ```apex CronBuilder month(String month); ``` **Example** ```apex CronBuilder cron = new CronBuilder() .month('6') .dayOfMonth('1'); ``` #### dayOfWeek Sets the day of week value for the cron expression (1-7, where 1=Sunday). **Signature** ```apex CronBuilder dayOfWeek(String dayOfWeek); ``` **Example** ```apex CronBuilder cron = new CronBuilder() .dayOfWeek('2') // Monday .hour('9'); ``` #### optionalYear Sets the optional year value for the cron expression. **Signature** ```apex CronBuilder optionalYear(String optionalYear); ``` **Example** ```apex CronBuilder cron = new CronBuilder() .optionalYear('2024') .hour('12'); ``` #### buildForEveryXMinutes Creates multiple CronBuilder instances for execution every X minutes (max 30 minutes). **Signature** ```apex List buildForEveryXMinutes(Integer everyXMinutes); ``` **Example** ```apex CronBuilder baseCron = new CronBuilder().hour('9'); List crons = baseCron.buildForEveryXMinutes(15); // Runs every 15 minutes during hour 9 ``` #### everyHour Sets the cron to run every hour at the specified minute. **Signature** ```apex CronBuilder everyHour(Integer minute); ``` **Example** ```apex CronBuilder cron = new CronBuilder() .everyHour(30); // Run at 30 minutes past every hour ``` #### everyXHours Sets the cron to run every X hours at the specified minute (max 12 hours). **Signature** ```apex CronBuilder everyXHours(Integer everyXHours, Integer minute); ``` **Example** ```apex CronBuilder cron = new CronBuilder() .everyXHours(4, 0); // Run every 4 hours at minute 0 ``` #### everyDay Sets the cron to run every day at the specified hour and minute. **Signature** ```apex CronBuilder everyDay(Integer hour, Integer minute); ``` **Example** ```apex CronBuilder cron = new CronBuilder() .everyDay(14, 30); // Run daily at 2:30 PM ``` #### everyXDays Sets the cron to run every X days at the specified hour and minute (max 15 days). **Signature** ```apex CronBuilder everyXDays(Integer everyXDays, Integer hour, Integer minute); ``` **Example** ```apex CronBuilder cron = new CronBuilder() .everyXDays(3, 9, 0); // Run every 3 days at 9:00 AM ``` #### everyMonth Sets the cron to run every month on the specified day, hour, and minute. **Signature** ```apex CronBuilder everyMonth(Integer day, Integer hour, Integer minute); ``` **Example** ```apex CronBuilder cron = new CronBuilder() .everyMonth(1, 2, 0); // Run on 1st of every month at 2:00 AM ``` #### everyXMonths Sets the cron to run every X months on the specified day, hour, and minute (max 6 months). **Signature** ```apex CronBuilder everyXMonths(Integer everyXMonths, Integer dayOfMonth, Integer hour, Integer minute); ``` **Example** ```apex CronBuilder cron = new CronBuilder() .everyXMonths(3, 15, 10, 30); // Run every 3 months on 15th at 10:30 AM ``` #### getCronExpression Returns the complete cron expression string. **Signature** ```apex String getCronExpression(); ``` **Example** ```apex CronBuilder cron = new CronBuilder() .everyDay(14, 0); String cronExpr = cron.getCronExpression(); // Returns: "0 0 14 * * ? *" ``` ### Schedule #### schedule Schedules the job with the configured options. Returns a list of Async.Result objects (one per cron expression). **Signature** ```apex List schedule(); ``` **Example** ```apex List results = Async.schedulable(new MySchedulableJob()) .name('Every Hour Processing') .cronExpression(new CronBuilder().everyHour(1)) .schedule(); ``` Returns a list with one result per cron expression. Each `result.salesforceJobId` contains the Salesforce Job Id. --- --- url: https://async.beyondthecloud.dev/api/async-mock.md --- # AsyncMock API Apex class `AsyncMock.cls`. **Common Queueable mocking example:** ```apex @IsTest static void shouldMockQueueableContext() { AsyncMock.whenQueueable('account-creator') .thenReturn(new AsyncMock.MockQueueableContext()); Test.startTest(); Async.queueable(new AccountCreatorJob()) .mockId('account-creator') .enqueue(); Test.stopTest(); } ``` **Common job failure mocking example:** ```apex @IsTest static void shouldMockJobFailure() { AsyncMock.whenQueueable('flaky-job') .thenThrow(new CalloutException('service unavailable')); Test.startTest(); Async.queueable(new SyncAccountsJob()).mockId('flaky-job').retry(1).enqueue(); Test.stopTest(); // The job never runs. It fails, retries and records exactly as if it had thrown. } ``` **Common Finalizer mocking example:** ```apex @IsTest static void shouldMockFinalizerContext() { AsyncMock.whenFinalizer('error-handler') .thenThrow(new DmlException('Parent job failed')); Test.startTest(); Async.queueable(new ParentJobWithFinalizer('error-handler')).enqueue(); Test.stopTest(); } ``` ::: tip For finalizer mocking, the `mockId` must be set on the finalizer itself (via `attachFinalizer()` inside `work()`), not on the parent job. ::: For testing patterns and best practices, see [Testing Async Jobs](/explanations/testing-async-jobs). ## Methods The following are methods for using AsyncMock in tests: [**INIT - Finalizer**](#init---finalizer) * [`whenFinalizer(String mockId)`](#whenfinalizer) * [`whenFinalizerDefault()`](#whenfinalizerdefault) [**INIT - Queueable**](#init---queueable) * [`whenQueueable(String mockId)`](#whenqueueable) * [`whenQueueableDefault()`](#whenqueueabledefault) [**Build - FinalizerMockSetup**](#build---finalizermocksetup) * [`thenReturn(FinalizerContext ctx)`](#thenreturn-finalizercontext) * [`thenReturn(ParentJobResult result)`](#thenreturn-parentjobresult) * [`thenThrow(Exception ex)`](#thenthrow) [**Build - QueueableMockSetup**](#build---queueablemocksetup) * [`thenReturn(QueueableContext ctx)`](#thenreturn-queueablecontext) * [`thenReturn(Id jobId)`](#thenreturn-id) * [`thenThrow(Exception ex)`](#thenthrow-1) [**Utility**](#utility) * [`reset()`](#reset) * [`hasFinalizerMock(String mockId)`](#hasfinalizermock) * [`hasQueueableMock(String mockId)`](#hasqueueablemock) * [`getFinalizerContext(String mockId)`](#getfinalizercontext) * [`getQueueableContext(String mockId)`](#getqueueablecontext) [**Mock Context Classes**](#mock-context-classes) * [`MockFinalizerContext`](#mockfinalizercontext) * [`MockQueueableContext`](#mockqueueablecontext) ### INIT - Finalizer #### whenFinalizer Sets up a mock for a specific finalizer identified by mockId. **Signature** ```apex static FinalizerMockSetup whenFinalizer(String mockId); ``` **Example** ```apex AsyncMock.whenFinalizer('error-handler') .thenReturn(ParentJobResult.SUCCESS); ``` #### whenFinalizerDefault Sets up a default mock that applies when no specific mockId matches or when a specific mock is exhausted. **Signature** ```apex static FinalizerMockSetup whenFinalizerDefault(); ``` **Example** ```apex AsyncMock.whenFinalizerDefault() .thenReturn(ParentJobResult.SUCCESS); Test.startTest(); Async.queueable(new ParentJobWithFinalizer('job-1')).enqueue(); Async.queueable(new ParentJobWithFinalizer('job-2')).enqueue(); Test.stopTest(); ``` ### INIT - Queueable #### whenQueueable Sets up a mock for a specific queueable job identified by mockId. **Signature** ```apex static QueueableMockSetup whenQueueable(String mockId); ``` **Example** ```apex AsyncMock.whenQueueable('account-creator') .thenReturn(new AsyncMock.MockQueueableContext()); ``` #### whenQueueableDefault Sets up a default mock that applies when no specific mockId matches or when a specific mock is exhausted. **Signature** ```apex static QueueableMockSetup whenQueueableDefault(); ``` **Example** ```apex AsyncMock.whenQueueableDefault() .thenReturn(new AsyncMock.MockQueueableContext()); ``` ### Build - FinalizerMockSetup #### thenReturn (FinalizerContext) Adds a `FinalizerContext` to the mock queue. Each call to `getFinalizerContext` consumes one context from the queue (FIFO). **Signature** ```apex FinalizerMockSetup thenReturn(FinalizerContext ctx); ``` **Example** ```apex AsyncMock.whenFinalizer('multi-test') .thenReturn(new AsyncMock.MockFinalizerContext() .setResult(ParentJobResult.SUCCESS)) .thenReturn(new AsyncMock.MockFinalizerContext() .setResult(ParentJobResult.UNHANDLED_EXCEPTION)); ``` #### thenReturn (ParentJobResult) Convenience method that creates a `MockFinalizerContext` with the specified result. **Signature** ```apex FinalizerMockSetup thenReturn(ParentJobResult result); ``` **Example** ```apex AsyncMock.whenFinalizer('my-job') .thenReturn(ParentJobResult.SUCCESS) .thenReturn(ParentJobResult.UNHANDLED_EXCEPTION) .thenReturn(ParentJobResult.SUCCESS); ``` #### thenThrow Creates a `MockFinalizerContext` with `UNHANDLED_EXCEPTION` result and the specified exception. **Signature** ```apex FinalizerMockSetup thenThrow(Exception ex); ``` **Example** ```apex AsyncMock.whenFinalizer('error-handler') .thenThrow(new DmlException('Parent job failed')); Test.startTest(); Async.queueable(new ParentJobWithFinalizer('error-handler')).enqueue(); Test.stopTest(); Account errorLog = [SELECT Name, Description FROM Account LIMIT 1]; Assert.areEqual('Parent job failed', errorLog.Description); ``` ### Build - QueueableMockSetup #### thenReturn (QueueableContext) Adds a `QueueableContext` to the mock queue. Each call to `getQueueableContext` consumes one context from the queue (FIFO). **Signature** ```apex QueueableMockSetup thenReturn(QueueableContext ctx); ``` **Example** ```apex AsyncMock.whenQueueable('my-job') .thenReturn(new AsyncMock.MockQueueableContext().setJobId('707xx0000000001')); ``` #### thenReturn (Id) Convenience method that creates a `MockQueueableContext` with the specified job ID. **Signature** ```apex QueueableMockSetup thenReturn(Id jobId); ``` **Example** ```apex AsyncMock.whenQueueable('my-job') .thenReturn('707xx0000000001AAA'); ``` #### thenThrow Makes the job fail instead of running. The exception is raised where the job body would have run, so everything downstream behaves as if the job had thrown it itself: rollback, retry classification and backoff, the `FAILED` `AsyncResult__c`, and any chain or chunk failure policy. Use it to test a failure path without writing a job class that exists only to blow up. **Signature** ```apex QueueableMockSetup thenThrow(Exception ex); ``` **Example** ```apex AsyncMock.whenQueueable('flaky-job') .thenThrow(new CalloutException('service unavailable')); Test.startTest(); Async.queueable(new SyncAccountsJob()) .mockId('flaky-job') .retry(1) .enqueue(); Test.stopTest(); ``` ::: tip Picking which run fails The mock is a queue, so mixing `thenReturn` and `thenThrow` chooses which execution fails. On a [chunk run](/api/chunk) that means choosing which page fails, since every page consumes one entry: ```apex AsyncMock.whenQueueable('recalc-run') .thenReturn(new AsyncMock.MockQueueableContext()) // page 1 succeeds .thenThrow(new CalloutException('boom')) // page 2 fails .thenReturn(new AsyncMock.MockQueueableContext()); // page 3 succeeds Async.chunk(new AccountRecalcJob(), ChunkSource.of(records)) .chunkSize(2) .mockId('recalc-run') .stopRemainingChunksOnFailure() .enqueue(); ``` ::: ### Configuration #### jobSettings Injects `QueueableJobSetting__mdt` records for the duration of a test. Custom Metadata cannot be inserted in Apex, so without this there is no way to test behaviour that depends on it. This makes all of it testable: retry defaults, backoff, retryable exceptions, result creation, disabled jobs and the registered logger. Records are keyed by `QueueableJobName__c`, so use `All` for the org-wide default and a class name to override a single job, exactly as in real configuration. **Signature** ```apex static void jobSettings(List settings); ``` **Example** ```apex @IsTest static void shouldRouteFailuresToOurLogger() { AsyncMock.jobSettings( new List{ new QueueableJobSetting__mdt( QueueableJobName__c = 'All', LoggerClass__c = 'MyAsyncLogger', MaxRetries__c = 2 ) } ); Test.startTest(); Async.queueable(new ImportJob()).enqueue(); Test.stopTest(); // assert against whatever MyAsyncLogger recorded } ``` On a packaged install the type and its fields carry the namespace: ```apex new btcdev__QueueableJobSetting__mdt( btcdev__QueueableJobName__c = 'All', btcdev__LoggerClass__c = 'MyAsyncLogger' ); ``` [`reset()`](#reset) clears injected settings along with everything else. ### Utility #### reset Clears all mock setups (both specific and default mocks) and any settings injected with [`jobSettings`](#jobsettings). **Signature** ```apex static void reset(); ``` **Example** ```apex AsyncMock.whenFinalizer('test').thenReturn(ParentJobResult.SUCCESS); AsyncMock.whenQueueable('test').thenReturn(new AsyncMock.MockQueueableContext()); AsyncMock.reset(); Assert.isNull(AsyncMock.getFinalizerContext('test')); Assert.isNull(AsyncMock.getQueueableContext('test')); ``` #### hasFinalizerMock Checks if a finalizer mock exists for the given mockId or if a default mock is configured. **Signature** ```apex static Boolean hasFinalizerMock(String mockId); ``` **Example** ```apex AsyncMock.whenFinalizer('my-job').thenReturn(ParentJobResult.SUCCESS); Assert.isTrue(AsyncMock.hasFinalizerMock('my-job')); Assert.isFalse(AsyncMock.hasFinalizerMock('other-job')); ``` #### hasQueueableMock Checks if a queueable mock exists for the given mockId or if a default mock is configured. **Signature** ```apex static Boolean hasQueueableMock(String mockId); ``` **Example** ```apex AsyncMock.whenQueueable('my-job').thenReturn(new AsyncMock.MockQueueableContext()); Assert.isTrue(AsyncMock.hasQueueableMock('my-job')); Assert.isFalse(AsyncMock.hasQueueableMock('other-job')); ``` #### getFinalizerContext Retrieves and removes the next `FinalizerContext` from the mock queue. Falls back to default mock if specific mock is exhausted. **Signature** ```apex static FinalizerContext getFinalizerContext(String mockId); ``` **Example** ```apex AsyncMock.whenFinalizerDefault().thenReturn(ParentJobResult.SUCCESS); AsyncMock.whenFinalizer('special').thenThrow(new DmlException('Error')); FinalizerContext ctx1 = AsyncMock.getFinalizerContext('special'); FinalizerContext ctx2 = AsyncMock.getFinalizerContext('special'); Assert.areEqual(ParentJobResult.UNHANDLED_EXCEPTION, ctx1.getResult()); Assert.areEqual(ParentJobResult.SUCCESS, ctx2.getResult()); // Falls back to default ``` #### getQueueableContext Retrieves and removes the next `QueueableContext` from the mock queue. Falls back to default mock if specific mock is exhausted. **Signature** ```apex static QueueableContext getQueueableContext(String mockId); ``` **Example** ```apex AsyncMock.whenQueueableDefault().thenReturn(new AsyncMock.MockQueueableContext()); AsyncMock.whenQueueable('special').thenReturn(new AsyncMock.MockQueueableContext()); QueueableContext ctx1 = AsyncMock.getQueueableContext('special'); QueueableContext ctx2 = AsyncMock.getQueueableContext('special'); Assert.isNotNull(ctx1); Assert.isNotNull(ctx2); // Falls back to default ``` ### Mock Context Classes #### MockFinalizerContext Implements `System.FinalizerContext` for test scenarios. **Signature** ```apex public class MockFinalizerContext implements System.FinalizerContext ``` **Build Methods** | Method | Description | |--------|-------------| | `setResult(ParentJobResult result)` | Sets the parent job result | | `setException(Exception ex)` | Sets exception and auto-sets result to `UNHANDLED_EXCEPTION` | | `setJobId(Id jobId)` | Sets the async apex job ID | **Interface Methods** | Method | Description | |--------|-------------| | `getResult()` | Returns the configured `ParentJobResult` | | `getException()` | Returns the configured exception | | `getAsyncApexJobId()` | Returns the configured job ID | | `getRequestId()` | Returns `'mock-request-id'` | **Example** ```apex ErrorHandlerFinalizer finalizer = new ErrorHandlerFinalizer(); finalizer.finalizerCtx = new AsyncMock.MockFinalizerContext() .setResult(ParentJobResult.UNHANDLED_EXCEPTION) .setException(new DmlException('Direct test error')); finalizer.work(); ``` #### MockQueueableContext Implements `System.QueueableContext` for test scenarios. **Signature** ```apex public class MockQueueableContext implements System.QueueableContext ``` **Build Methods** | Method | Description | |--------|-------------| | `setJobId(Id jobId)` | Sets the job ID | | `setException(Exception ex)` | Makes the job fail with this exception instead of running | **Interface Methods** | Method | Description | |--------|-------------| | `getJobId()` | Returns the configured job ID | | `getException()` | Returns the failure the job will be given, if any | **Example** ```apex AccountCreatorJob job = new AccountCreatorJob('Direct Test'); job.queueableCtx = new AsyncMock.MockQueueableContext(); job.work(); ``` --- --- url: >- https://async.beyondthecloud.dev/explanations/initial-scheduled-queuable-batch-job.md --- # Initial Queueable Chain Schedulable Explanation ## TL;DR Due to the fact that we cannot: * determine if current enqueued queueable job is the last one in the Apex transaction, * pass QueueableChain job as reference to `System.enqueueJob()`, and later in Apex transaction add new jobs to this chain, * abort queueable job and preserve the Queueable Job limits (running `System.enqueueJob()` and later `System.abortJob()` doesn't revert the Queueable Job limits), The only option is to *somehow* enqueue a queueable chain, and in case of next `System.enqueueJob()` call, abort that job and enqueue a new one with additional job. This *somehow* approach, is exactly what the initial queueable chain schedulable implementation does. ## Why Do We Need QueueableChainSchedulable? ### The Challenge: Queueable Job Limits Salesforce has strict limits on queueable jobs: * **Maximum 50 queueable jobs** can be enqueued per transaction * Once you hit this limit, `System.enqueueJob()` will throw an exception * Using `System.abortJob()` does not free up the queueable job limits * This creates a problem when you need to process more than 50 jobs efficiently ### The Goal: Efficient Job Processing To be efficient, Async Lib tries to enqueue as many queueable jobs as possible in the synchronous context. This means: 1. Enqueue jobs normally using `System.enqueueJob()` (jobs 1-50) 2. Once reaching 50 queueable jobs, switch to an alternative approach for the remaining jobs 3. Schedule `QueueableChainSchedulable` to handle jobs beyond the 50-job limit ### The Technical Problem The core issue is **we don't know how many more jobs will be enqueued** during the current transaction: ```apex // We're at 49 jobs enqueued Async.queueable(new Job50()).enqueue(); // This works fine // But what happens next? Async.queueable(new Job51()).enqueue(); // We need to handle this! Async.queueable(new Job52()).enqueue(); // And this! Async.queueable(new Job53()).enqueue(); // And this...? // ... potentially many more jobs Async.queueable(new JobXXXXX()).enqueue(); // How many more...? ``` **Why we can't just enqueue the chain at job #50:** * `System.enqueueJob()` only passes the **current state** of the job * If we enqueue a `QueueableJob` with chain details as the 50th job * And later try to add job #51 to that chain * **It won't work** because the chain was already enqueued and is immutable ### Failed Approach: Enqueue + Abort A logical solution might be: 1. Enqueue a job with the current chain state 2. If more jobs come in, abort the previous job and enqueue a new one with updated chain **However, this doesn't work because:** * Using `System.enqueueJob()` followed by `System.abortJob()` in the same transaction * **Still consumes the queueable job limits** * The limits are not restored when you abort * This means you quickly run out of limit slots ### The Solution: Schedulable context **System.schedule() has different behavior:** * Schedulable are **not tied to the same queueable job limits** * When using `System.abortJob()` on a schedulable, **the limits are properly restored** * This allows us to execute, abort, and re-execute as many times as needed **How the QueueableChainSchedulable works:** 1. When we hit the queueable limit, schedule a initial queueable chain schedulable with the current chain state **to run 1 minute in future** 2. If more queueable jobs are added during the transaction: * Abort the previous schedulable * Schedule a new initial queueable chain schedulable with the updated chain (including new queueable jobs) 3. Repeat as needed until the transaction ends 4. The final schedulable executes with all the accumulated queueable jobs ## Real-World Example Here's what happens when you enqueue 75 queueable jobs: ```apex // In your code for (Integer i = 1; i <= 75; i++) { Async.queueable(new ProcessingJob(i)).enqueue(); } ``` **Behind the scenes:** 1. **Jobs 1-50**: Enqueued normally using `System.enqueueJob()` 2. **Job 51**: Triggers QueueableChainSchedulable creation, scheduled for +1 minute 3. **Jobs 52-75**: Each addition aborts previous schedulable and schedules new one with updated chain 4. **Final result**: One queueable chain schedulable is created, containing queueable jobs 51-75 in the chain ## Benefits of This Approach ✅ **No Limit Errors**: Never throws "Too many queueable jobs" exceptions\ ✅ **Efficient Processing**: Uses direct queueable jobs when possible\ ✅ **Automatic Fallback**: Seamlessly switches to schedulable processing when needed\ ✅ **Complete Chain Execution**: All jobs execute in the correct order\ ✅ **Error Recovery**: Handles various Salesforce governor limit scenarios --- --- url: https://async.beyondthecloud.dev/explanations/job-state-between-runs.md --- # Job State Between Runs ## TL;DR A job object can run more than once. A retry re-runs it, and a chunk run re-runs it once per page. Both times it keeps whatever the previous run left on it. Async Lib makes you say what should happen. Two ways, pick either: ```apex // You clear your own state public class ImportJob extends QueueableJob implements Async.Retryable { private List inserted = new List(); public override void work() { ... } public void resetBeforeRetry(Integer attempt) { inserted.clear(); } } ``` ```apex // Or Async Lib replays the job from the state it had when you enqueued it Async.queueable(new ImportJob()) .retry(3) .restoreStateOnRetry() .enqueue(); ``` An **empty body is a valid answer**, and it is how you say "keep the state, that is what I want": ```apex public void resetBeforeRetry(Integer attempt) { } ``` That is exactly the 2.x behaviour. The next attempt starts with everything the failed one left behind, which is what you want for a job that resumes where it stopped, or for a job that holds nothing worth clearing. The only thing 3.0.0 changes is that you had to decide, rather than get it by default without knowing. There is deliberately no builder flag for this. The empty method lives on the job, where anyone reading the job can see the decision, and it stays true no matter where the job is enqueued from. A flag at the call site would have to be repeated at every call site and could disagree between them. ## Why This Exists Salesforce serializes your job when it is enqueued and hands the same object graph back on the next run. Async Lib clones the job between attempts and between pages, but a clone of a dirty job is still dirty. So this happens without you noticing: ```apex public class ImportJob extends QueueableJob { private List inserted = new List(); public override void work() { for (Account a : accounts) { insert a; inserted.add(a.Id); } publish(inserted); // attempt 2 publishes attempt 1's ids as well } } ``` Attempt 1 inserts 50 records and throws. Attempt 2 starts with `inserted` already holding 50 ids, adds 50 more, and publishes 100. Nothing errors. The job reports success. Chunking has the identical problem on a different axis. Page 2 starts with page 1's buffers. ## The Two Hazards | Event | Interface | Hook | Builder alternative | | ----- | --------- | ---- | ------------------- | | a failed attempt is retried | `Async.Retryable` | `resetBeforeRetry(Integer attempt)` | `restoreStateOnRetry()` | | a chunk run moves to the next page | `Async.ChunkResettable` | `resetBeforeNextChunk(Integer pageNumber)` | `restoreStateOnNextChunk()` | They are separate on purpose. A chunk job that keeps a running total across pages but wants a clean slate when a page is retried is a normal thing to write, and one combined hook could not express it. A job that both chunks and retries declares both: ```apex public class ImportChunk extends ChunkJob implements Async.Retryable, Async.ChunkResettable { private List pending = new List(); private Integer totalProcessed = 0; public override void work(List page) { ... } public void resetBeforeRetry(Integer attempt) { pending.clear(); // the failed attempt's work is gone } public void resetBeforeNextChunk(Integer pageNumber) { pending.clear(); // totalProcessed deliberately survives } } ``` ## What Gets Restored `restoreStateOnRetry()` and `restoreStateOnNextChunk()` take a deep copy of the job when it is enqueued and replay from that copy. Only **fields you declared** come back. Everything Async Lib owns is progression and is carried forward from the live run, so a retry still knows it is attempt 3 and a chunk page still knows where it is in the source. | Carried forward from the live run | Restored to its enqueue-time value | | --------------------------------- | ---------------------------------- | | `retryAttempt`, `retryHistory`, backoff delay | every field your subclass declares | | chunk position, page count, source | | | job and chain ids, sequence | | | failure info, skip status, processed flags | | Because the chunk position is carried rather than copied, the `ChunkSource` is never serialized. A `Database.Cursor` source works with `restoreStateOnNextChunk()` exactly like an in-memory one. ## Cost The restore options take a deep copy, which costs roughly twice the job's size in heap while it is being made. Measured, a 1 MB job needs about 2 MB, so the practical ceiling is around 2 MB of job state from a synchronous caller. The hooks cost nothing. If your reset is a couple of `clear()` calls, prefer the hook. In a **namespaced package install** the deep copy needs one line of help from your namespace. Override `cloneForDeepCopy()` on the job, or extend one of the ready-made base classes that do it for you. The base classes are a convenience, not a requirement, and either route works. See [Deep Clone in Packages](/explanations/deep-clone-in-packages). The hooks need none of this, which is another reason to prefer them. ## Migrating From `resetForRetry()` `resetForRetry()` is superseded and **no longer called**. Move its body: ```apex // Before public override void resetForRetry() { inserted.clear(); } // After public class ImportJob extends QueueableJob implements Async.Retryable { public void resetBeforeRetry(Integer attempt) { inserted.clear(); } } ``` The old method still exists and still compiles, because removing it would break the package install in every org that referenced it. It just does nothing. You will not miss this quietly. Any job configuring retry without declaring a reset throws at enqueue, in your own transaction, so it fails in your tests the first time you run them. ## When The Gate Fires At `.enqueue()` or `.chain()`, synchronously, before anything is sent to the queue: * a job with `retry(n)` that neither implements `Async.Retryable` nor calls `restoreStateOnRetry()` * a `ChunkJob` that neither implements `Async.ChunkResettable` nor calls `restoreStateOnNextChunk()` Retry configured through `QueueableJobSetting__mdt` cannot bypass the check either, but it does not throw. An admin can edit Custom Metadata in production with no deploy and no test run, and the `All` record reaches every job in the org, so refusing to enqueue would turn one typo into an org-wide outage. Instead, **retry is simply not applied** to a job that has not declared how its state resets, the job runs once as its code was written and tested to, and the reason is recorded on the job. See [Configuration Safety](/explanations/configuration-safety). --- --- url: https://async.beyondthecloud.dev/explanations/configuration-safety.md --- # Configuration Safety ## The rule > **Mistakes in Apex throw. Mistakes in Custom Metadata degrade and warn.** Async Lib refuses to enqueue a job that is wrong in code. It never refuses to enqueue a job because a Custom Metadata record is wrong. ## Why | | Wrong Apex | Wrong `QueueableJobSetting__mdt` | | --- | --- | --- | | Changed by | a developer | an admin | | Needs a deploy | yes | **no** | | Runs your tests first | yes | **no** | | Reaches | the one job just written | **every job in the org**, via the `All` record | A typo in the `All` record would otherwise stop every async job in production, with no test run and no deploy to catch it first. Losing a retry costs you a behaviour. Refusing to enqueue stops the business. ## What happens instead Each case degrades, records the reason on the job, and writes it to the debug log at `ERROR`. Nothing is thrown and the job runs. | Configuration mistake | Result | | --- | --- | | `MaxRetries__c` set for a job that declares no reset | retry not applied, job runs once | | `BackoffStrategy__c` is not a known strategy | no backoff, retries run without delay | | `MaxRetries__c` above the framework cap | clamped to the cap | | `LoggerClass__c` cannot be resolved | no logger, jobs run normally | | `JobSerializerClass__c` cannot be resolved, or is not an `Async.JobSerializer` | no payload stored, the result records `NotSerializable`, job runs normally | Every fallback degrades toward doing **less**, never toward doing something the developer did not ask for. Skipping retry is safe, because the job then runs exactly once, which is what its code was written and tested against. Silently *enabling* retry on a job that never declared how its state resets would not be. Warnings append to the job's retry history, so they reach `AsyncResult__c.RetryHistory__c` when result creation is enabled. Each one names the record, the job, what was skipped, and the fix. ## What still throws Anything a developer wrote, because it cannot escape their own test run: * `retry(n)` or `Async.chunk(...)` without a declared reset, see [Job State Between Runs](/explanations/job-state-between-runs) * `retry(-1)`, or a retry count above the cap passed in Apex * `delay()` combined with `asyncOptions()` * `dependsOn(Async.afterPrevious())` with no previous job * `Async.requeue(...)` asked for more payload than it can hold in one call These throw at `.enqueue()` or `.chain()`, synchronously, in the caller's transaction. ## Consequence worth knowing Setting `MaxRetries__c` on the `All` record enables retry only for jobs that have declared how their state resets. The rest keep running as before and say why in their history. That is intentional. The alternative is an admin silently enabling state-carrying retries across an entire org, which is the bug [Job State Between Runs](/explanations/job-state-between-runs) exists to prevent. --- --- url: https://async.beyondthecloud.dev/explanations/logging.md --- # Logging ## TL;DR Register one class, and every async job in the org reports to it. ```apex global class AsyncJobLogger implements Async.OnJobFailed { public void onJobFailed(Async.FailureContext ctx) { Logger.error('Async job failed: ' + ctx.className, ctx.failure.message); Logger.saveLog(); } } ``` Then set `LoggerClass__c` to `AsyncJobLogger` on the `All` record of `QueueableJobSetting__mdt`. That is the whole setup. ::: warning The class must be `global` Async Lib resolves your class by name from inside its own namespace, and `Type.forName` only reaches a subscriber class declared `global`. A `public` class resolves to null and nothing is logged. Only the **class** needs `global`. The methods stay `public`. On a source deploy there is no boundary, so keep the class `public` like any other. Add `global` if you later switch to the package; it is on the [switching list](/introduction/source-deploy#switching-to-the-package-later). The full packaged-install checklist is at [Installing as a Package](/introduction/packaged-install). ::: ## Events Implement only the ones you want. Each is a separate interface, so a logger that only cares about failures implements one method and nothing else. | Interface | Fires | Context | | --------- | ----- | ------- | | `Async.OnJobEnqueued` | a job is added to a chain | `JobContext` | | `Async.OnJobSucceeded` | a job finished without failing | `JobContext` | | `Async.OnJobFailed` | a job failed with no attempts left | `FailureContext` | | `Async.OnRetryEnqueued` | an attempt failed and another is queued | `FailureContext` | A job that fails with `retry(2)` and never succeeds produces `OnRetryEnqueued`, `OnRetryEnqueued`, `OnJobFailed`. Every failed attempt fires exactly one event, so the two together tell you whether to warn or to page. There is no overlap and no double counting. Chunk runs fire `OnJobEnqueued` per page, because each page really is queued separately. ## Two layers, one vocabulary The same interfaces work on a **job**, with no Custom Metadata at all: ```apex public class ImportJob extends QueueableJob implements Async.OnJobFailed { public override void work() { ... } public void onJobFailed(Async.FailureContext ctx) { // just this job } } ``` Both fire for the same event, and the job's own listener runs first. Use the job listener for one-off behaviour and the registered class for the org-wide sink. Neither needs the other. ## Attaching your own metadata `info(...)` puts arbitrary key/value pairs on a job, and they arrive on every context. This is how you route alerts by team, package or anything else you own: ```apex Async.queueable(new ImportJob()) .info('team', 'platform') .info('package', 'billing') .enqueue(); ``` ```apex public void onJobFailed(Async.FailureContext ctx) { String team = ctx.info.get('team'); } ``` It survives serialization, retries and chunk pages, because it travels on the job. ## Per-job override `LoggerClass__c` resolves job-first, then falls back to the `All` record, the same way retry settings do: | Record | `LoggerClass__c` | Result | | ------ | ---------------- | ------ | | `All` | `AsyncJobLogger` | every job goes here | | `ImportJob` | `ImportJobLogger` | that job goes here instead | | `ImportJob` | blank | that job falls back to `All` | ## A logger that throws cannot break a job Every listener call is wrapped. If yours throws, the failure is written to the debug log and the job carries on untouched. By the time most events fire the job has already done its work, so failing it over a logging problem would turn an observability problem into a data problem. The same applies to a `LoggerClass__c` that cannot be resolved: jobs keep running, and the reason is recorded. See [Configuration Safety](/explanations/configuration-safety). ## Testing your logger Custom Metadata cannot be inserted in Apex, so register the logger through `AsyncMock` instead. This is the only way to assert that the framework actually routes to you: ```apex @IsTest static void shouldLogFailures() { AsyncMock.jobSettings( new List{ new QueueableJobSetting__mdt( QueueableJobName__c = 'All', LoggerClass__c = 'MyAsyncLogger' ) } ); Test.startTest(); Async.queueable(new FailingJob()).continueOnJobExecuteFail().enqueue(); Test.stopTest(); // assert on whatever MyAsyncLogger recorded } ``` The same call covers every other Custom Metadata driven behaviour: retry defaults, backoff, retryable exceptions, result creation and disabled jobs. See [AsyncMock.jobSettings](/api/async-mock#jobsettings). ## Nebula Logger adapter ```apex global class NebulaAsyncLogger implements Async.OnJobFailed, Async.OnRetryEnqueued { public void onJobFailed(Async.FailureContext ctx) { Logger.error( String.format( 'Async job {0} failed after {1} attempt(s): {2}', new List{ ctx.className, String.valueOf(ctx.retryAttempt + 1), ctx.failure?.message } ) ); Logger.setScenario(ctx.info.get('team')); Logger.saveLog(); } public void onRetryEnqueued(Async.FailureContext ctx) { Logger.warn( 'Async job ' + ctx.className + ' attempt ' + ctx.retryAttempt + ' failed, retrying in ' + ctx.nextAttemptDelayMinutes + 'm' ); Logger.saveLog(); } } ``` `saveLog()` is called inside the listener on purpose. Each Queueable execution is its own transaction, so there is no later point at which to flush. ## Scope Queueable only, including chunk runs. Batchable and Schedulable do not fire these events yet, because the framework does not own their base classes. `AsyncResult__c` is untouched and orthogonal. Use either, both, or neither. --- --- url: https://async.beyondthecloud.dev/explanations/requeue.md --- # Requeue ## TL;DR A job failed, you fixed whatever caused it, and now you want it to run again with the same input. That is what requeue is for. ```apex Async.requeue(resultId); ``` ```apex Async.RequeueSummary summary = Async.requeue(failedResultIds); summary.requeued; // the results that were replayed summary.skipReasonByResultId; // the rest, and why each one was skipped summary.enqueueResult; // the chain the replays run in ``` To make this possible, Async Lib stores a snapshot of the job on its `AsyncResult__c` record. That is off by default, because the snapshot is a copy of whatever data the job was carrying, and you should decide whether that belongs on a queryable object in your org. ## Turning it on Set `StoreJobPayload__c` to `Yes` on the `All` record of `QueueableJobSetting__mdt`, or on a record for a single job. It is a picklist rather than a checkbox on purpose. A job record can say `No`, and that beats a `Yes` on `All`. In practice the decision usually looks like "store payloads for everything, except the one job that carries sensitive data", and a checkbox cannot express that. ## You do not need `CreateResult__c` `CreateResult__c` writes a row for every job on every run, so most orgs with real volume keep it off. If requeue depended on it, the orgs that need it most could not use it. Instead, turning payload storage on writes a result row for **failed** and **skipped** jobs, no matter what `CreateResult__c` says. Successful jobs still obey `CreateResult__c`, because there is nothing to replay about a job that worked. The extra rows you get are bounded by your failure rate. Skipped jobs are included because a job skipped over an unmet dependency never actually failed, and once you fix the blocker it is exactly the one you want back. ## What the snapshot holds The job as you handed it to `enqueue()`, before it ran. Not the failed attempt. A failed attempt has already mutated its own state, and replaying from that is the bug [the state gate](/explanations/job-state-between-runs) exists to prevent. | Travels with the payload | Does not | | ------------------------ | -------- | | your own fields, `retry(n)`, `info(...)` | `backoff(...)`, `dependsOn(...)`, chain position, retry defaults from Custom Metadata | So a replay retries the way the original did, but it starts a fresh chain, takes the retry defaults from today's Custom Metadata, and does not carry dependencies from the old chain. Chunk pages and finalizers are never stored. A page needs its source and a finalizer needs its parent job, and neither of those survives on a record. They get `NotSerializable` on the row so you can see that it was deliberate. ## Requeue replays data, not intent ::: warning Think before you requeue a job whose class you just changed The payload was written by the class as it was when the job failed. It is rebuilt by the class as it is now. If your fix changed the shape or the meaning of a field, the replay is not the job you tested. ::: There are two ways this goes wrong, and only one of them is loud. **A renamed field arrives empty.** You renamed `accountIds` to `recordIds`. The payload still says `accountIds`, so the rebuilt job starts with `recordIds` as `null`. It processes nothing, or throws on the first dereference. A changed type, say `String` to `Integer`, fails at rebuild instead and shows up in `skipReasonByResultId`. Either way, you notice. **A field that kept its name and changed its meaning does the opposite.** The job carried `Set accountIds` meaning "process these". Your fix changed `work()` so the set now means "skip these". The payload rebuilds cleanly, the set holds the same ids, and the replay skips exactly the records it was supposed to process. Nothing fails. Nothing is logged. Requeue is the right tool when the fix was outside the job: an integration was down, a validation rule was wrong, a permission was missing. When the fix changed what the job's own fields mean, do not requeue. Enqueue it fresh with the input you want. ## On a packaged install, register a serializer ::: warning Required when Async Lib is installed as a package JSON cannot cross a namespace boundary in either direction. Async Lib can neither store your job nor rebuild it from inside its own namespace, regardless of whether your class is `public` or `global`. Both halves have to run in your code. ::: The class is ready to copy from [`extras/classes/AsyncJobSerializer.cls`](https://github.com/beyond-the-cloud-dev/async-lib/tree/main/extras/classes): ```apex global class AsyncJobSerializer implements btcdev.Async.JobSerializer { public String serialize(btcdev.QueueableJob job) { return JSON.serialize(job); } public btcdev.QueueableJob deserialize(String className, String payload) { return (btcdev.QueueableJob) JSON.deserialize(payload, Type.forName(className)); } } ``` Register it once, in `JobSerializerClass__c` on the `All` record. The class has to be `global` for the same reason `LoggerClass__c` does: Async Lib resolves it by name from its own namespace, and `Type.forName` reaches nothing else. Only the class, the methods stay `public`. If you deployed the source instead, there is no boundary. Leave `JobSerializerClass__c` blank and Async Lib converts the job itself. The full checklist for a packaged install is at [Installing as a Package](/introduction/packaged-install). ## Reading the record | Field | Holds | | ----- | ----- | | `JobPayload__c` | the serialized job | | `PayloadSize__c` | its length in characters | | `RequeueStatus__c` | whether it can be replayed | | `RequeuedFrom__c` | the result this one was replayed from | `JobPayload__c` is a Long Text Area, and Long Text cannot be filtered on. `WHERE JobPayload__c != null` does not even compile. That is why `RequeueStatus__c` exists, and it is what you select by. | Status | Meaning | | ------ | ------- | | `Stored` | ready to replay | | `Requeued` | already replayed | | `TooLarge` | over 131,072 characters, nothing was stored | | `NotSerializable` | the job could not be converted, or it is a chunk page or a finalizer. The reason is in `RetryHistory__c` | | blank | payload storage was off for this job | A job that hits `TooLarge` is almost certainly carrying full SObjects. Carry record ids and re-query them inside `work()`. 131,072 characters holds roughly six thousand ids. ## Replaying in bulk ```apex Set failed = new Map([ SELECT Id FROM AsyncResult__c WHERE RequeueStatus__c = 'Stored' AND CreatedDate = LAST_N_HOURS:2 ]).keySet(); Async.requeue(failed); ``` All the replays go into **one chain** and run in sequence. That matters when requeue itself runs from a Queueable, where only one job can be enqueued per transaction. One chain costs one slot, and a replay that fails does not stop the ones after it. There is a limit of 2,000,000 characters of payload per call, checked before any payload is loaded. Over that, `Async.requeue` throws instead of dying halfway through. If you have more than that to replay, order by `PayloadSize__c` and batch. ## The trail ``` R1 (failed) <- R2 (failed) <- R3 ``` Each replay gets its own `AsyncResult__c` record in a new chain, linked back to its source by `RequeuedFrom__c`. The source is marked `Requeued`. That mark is what makes a scheduled "replay everything that failed" job safe: it never picks the same record up twice. There is no cap on how long the trail can get. Requeue is something a person does after fixing something, and `retry(n)` already covers the automated case with a bound. A cap here would only punish whoever fixed the bug on the third try. If a replay fails again, that is worth a look rather than another replay. ## If you put this behind a button, gate the button `Async.requeue` is not permission-gated, the same as every other `Async` call. Called from Apex that is fine: whoever can write the call could do anything else too. Once you expose it to end users, through an `@AuraEnabled` method, a Flow action or a screen, the user gets to pick which stored job runs, in system context, with the data it carried. Check a custom permission in your controller before you call it. ## Limits * Requeue replays one job, not its chain. The rest of the chain is not rebuilt. * `AsyncResultCleanupBatch` deletes old records, so a replay is bounded by your retention window. * `AsyncResultAccess` grants read on `JobPayload__c`. Review who holds that permission set before you turn storage on. ## Testing it `AsyncMock.jobSettings(...)` injects the settings, so none of this needs real Custom Metadata: ```apex AsyncMock.jobSettings(new List{ new QueueableJobSetting__mdt( QueueableJobName__c = 'All', StoreJobPayload__c = 'Yes' ) }); ``` --- --- url: https://async.beyondthecloud.dev/explanations/job-cloning.md --- # Job Cloning Explanation ## TL;DR In Salesforce Apex, everything is passed by value, what mean passing value itself, or the reference (the memory pointer) to it. Every time you are passing complex data types, the reference (memory pointer) is shared. When you enqueue a job using `Async.queueable(this).enqueue()`, you're passing the same instance that's already in the jobs processing list. Any changes to the job instance after enqueueing will affect both the original and the queued job, including critical QueueableJob properties like `isProcessed`. To prevent this, Async Lib clones every job when adding it to the processing queue. By default, it uses **soft cloning** (fast but shallow), with an option for **deep cloning** (slower but complete) when needed. The difference between soft and deep cloning is demonstrated in the `AsyncTest` class: * **[`shouldSoftCloneTheJob`](https://github.com/beyond-the-cloud-dev/async-lib/blob/v2.3.0/force-app/main/default/classes/AsyncTest.cls)** method: Shows how primitive properties are properly isolated * **[`shouldDeepCloneTheJob`](https://github.com/beyond-the-cloud-dev/async-lib/blob/v2.3.0/force-app/main/default/classes/AsyncTest.cls)** method: Demonstrates complete object isolation including complex types View AsyncTest examples for detailed test scenarios. ## Why Do We Need Job Cloning? ### The Problem: Reference Sharing When you enqueue a queueable job in Apex, you might think you're creating separate instances: ```apex public class MyJob extends QueueableJob { public String status = 'pending'; public List processedItems = new List(); public override void work() { this.status = 'processing'; this.processedItems.add('item1'); // Enqueue another instance of the same job Async.queueable(this).enqueue(); // ❌ PROBLEM! } } ``` **What actually happens:** * `this` refers to the **same object instance** * Both the current job and the newly enqueued job share the same memory reference * Changes to properties like `status`, `isProcessed`, or `processedItems` affect both jobs * This can lead to unexpected behavior and corrupted job state ### Real-World Impact Consider this scenario: ```apex MyJob job1 = new MyJob(); job1.status = 'initial'; // Enqueue the job Async.queueable(job1).enqueue(); // Later in the same transaction job1.status = 'modified'; // This change affects the enqueued job too! ``` **Without cloning:** * Both the local `job1` variable and the enqueued job point to the same object * Changing `job1.status` also changes the status of the enqueued job * Critical framework properties like `isProcessed` can be corrupted ### Framework Internal Impact Async Lib tracks job state using internal properties: ```apex public abstract class QueueableJob implements Queueable { public Boolean isProcessed = false; public Integer priority = 0; public String customJobId; // ... other tracking properties } ``` **Without cloning:** * When a job completes, `isProcessed` gets set to `true` * If the same instance is in the queue multiple times, all references show `isProcessed = true` * This breaks the framework's job tracking and execution logic ## The Solution: Job Cloning ### How Cloning Works When you enqueue a job, Async Lib automatically clones it: ```apex // Your code Async.queueable(myJob).enqueue(); // What happens internally QueueableJob clonedJob = myJob.clone(); // Creates a separate instance // Add clonedJob to the processing queue ``` This ensures that: ✅ Each enqueued job has its own memory space\ ✅ Changes to the original don't affect the enqueued job\ ✅ Framework properties remain isolated per job instance\ ✅ Job execution state is properly maintained ## Types of Cloning ### Soft Clone (Default) **How it works:** Uses Apex's standard `clone()` method, which performs a **shallow copy**: ```apex QueueableJob clonedJob = originalJob.clone(); ``` **What gets cloned:** * ✅ Primitive types: `String`, `Integer`, `Boolean`, `Decimal`, etc. * ✅ Simple collections of primitives * ❌ Complex objects (still shared by reference) * ❌ Nested objects and their properties **Example:** ```apex public class MyJob extends QueueableJob { public String name = 'test'; // ✅ Cloned public Integer count = 5; // ✅ Cloned public Account acc = new Account(); // ❌ Shared reference public List accounts; // ❌ Shared reference } ``` ### Deep Clone (Optional) **How it works:** Uses JSON serialization/deserialization to create a **complete copy**: ```apex QueueableJob clonedJob = (QueueableJob) JSON.deserialize( JSON.serialize(originalJob), QueueableJob.class ); ``` **What gets cloned:** * ✅ All primitive types * ✅ All complex objects * ✅ Nested objects and collections * ✅ Complete object hierarchy **When to use:** ```apex Async.queueable(myJob) .deepClone() // Enable deep cloning .enqueue(); ``` ### What Deep Clone Does Not Do Deep clone copies the job **as it is at the moment of cloning**. It does not reset anything. That matters for [`retry(...)`](/api/queueable#retry), because the retry clone is taken *after* `work()` has already run and mutated the job. A soft clone and a deep clone of a failed attempt both carry whatever that attempt accumulated: ```apex public class ImportJob extends QueueableJob implements Async.Retryable { public List processed = new List(); public override void work() { processed.add('batch'); // attempt 2 starts with attempt 1's entry still here callTheApiThatIsDown(); } public void resetBeforeRetry(Integer attempt) { processed.clear(); // this is the reset hook, not deepClone() } } ``` Use `deepClone()` to isolate a job from **the caller's** later mutations. Use [`resetBeforeRetry()`](/explanations/job-state-between-runs) to clear state **between attempts**. They solve different problems, and Async Lib will not let you enqueue a retrying job without picking one of the reset options. ## Performance Considerations ### Soft Clone Performance * **Speed**: Very fast (native Apex operation) * **Memory**: Minimal overhead * **CPU**: Negligible impact * **Recommended for**: Most use cases ### Deep Clone Performance * **Speed**: Slower (JSON serialization overhead) * **Memory**: Higher overhead (full object duplication) * **CPU**: More intensive * **Recommended for**: Jobs with complex object relationships ## Package Usage When Async Lib is installed as a package (with the `btcdev` namespace), deep clone requires an additional override due to Salesforce cross-namespace serialization limitations. See [Deep Clone in Packages](/explanations/deep-clone-in-packages) for details. ## Summary Job cloning is a critical feature that prevents reference corruption in Salesforce's pass-by-reference environment. Async Lib provides both soft and deep cloning options, allowing you to balance performance with data integrity based on your specific needs. The default soft clone handles most scenarios efficiently, while deep clone is available when complete object isolation is required. --- --- url: https://async.beyondthecloud.dev/explanations/deep-clone-in-packages.md --- # Deep Clone in Packages ## TL;DR When Async Lib is installed as a **namespaced package** (`btcdev`), `.deepClone()` needs one line of help from your namespace. Write this base class **once** and extend it instead of `btcdev.QueueableJob`: ```apex public abstract class BaseQueueableJob extends btcdev.QueueableJob { public static btcdev.QueueableJob deepCopy(btcdev.QueueableJob job) { return (btcdev.QueueableJob) JSON.deserialize( JSON.serialize(job), Type.forName(job.className) ); } public virtual override btcdev.QueueableJob cloneForDeepCopy() { return deepCopy(this); } public abstract class Finalizer extends btcdev.QueueableJob.Finalizer { public virtual override btcdev.QueueableJob cloneForDeepCopy() { return BaseQueueableJob.deepCopy(this); } } } ``` Chunk jobs get a second file, because Apex rejects an inner type inside an inner type and `btcdev.ChunkJob` is top level anyway: ```apex public abstract class BaseChunkJob extends btcdev.ChunkJob { public virtual override btcdev.QueueableJob cloneForDeepCopy() { return BaseQueueableJob.deepCopy(this); } } ``` It mirrors the shape of `btcdev.QueueableJob`, so swap the prefix and carry on: | Extend | instead of | | ------ | ---------- | | `BaseQueueableJob` | `btcdev.QueueableJob` | | `BaseQueueableJob.Finalizer` | `btcdev.QueueableJob.Finalizer` | | `BaseChunkJob` | `btcdev.ChunkJob` | Callouts are a marker, not a base class. Add `implements Database.AllowsCallouts` to any of them. ```apex public class MyJob extends BaseQueueableJob { public override void work() { // your logic } } ``` Every job that extends one of them is covered. There is no per-job override to write and nothing to remember when you add a new job. Callout capability survives the clone. The copy is the same concrete class, so a job marked `Database.AllowsCallouts` can still call out after a retry or on the next chunk page. Finalizers stay recognisable to the framework as `btcdev.QueueableJob.Finalizer`. Both files are in [`extras/classes/`](https://github.com/beyond-the-cloud-dev/async-lib/tree/main/extras/classes), ready to copy. Rename them to suit your project. If you deploy Async Lib **without a namespace** (Deploy button, `sf project deploy`), skip all of this. Everything already works. Everything else a packaged install needs is on one page: [Installing as a Package](/introduction/packaged-install). ## Per-Job Override `cloneForDeepCopy()` on the base class is left `virtual`, so a job with unusual needs can still take over: ```apex public class OddJob extends BaseQueueableJob { public override btcdev.QueueableJob cloneForDeepCopy() { // your own copy logic } } ``` You can also skip the base class entirely and override per job, naming the type explicitly: ```apex public class MyJob extends btcdev.QueueableJob { public override btcdev.QueueableJob cloneForDeepCopy() { return (btcdev.QueueableJob) JSON.deserialize(JSON.serialize(this), MyJob.class); } } ``` That is the older approach. It works, but you pay for it on every job. ## Why Is This Needed? Deep cloning uses `JSON.serialize()` and `JSON.deserialize()` to create a complete copy of a job instance. Two Salesforce platform behaviors make this fail across namespace boundaries: ### 1. Serialization Context `JSON.serialize(this)` behaves differently depending on **where** it executes. When called from inside the `btcdev` package code, Salesforce attaches internal platform metadata to `Queueable` implementors that cannot be serialized. The same object serializes fine from subscriber code. **From package code (fails):** ```apex // Inside btcdev.QueueableJob.cloneForDeepCopy() JSON.serialize(this); // System.JSONException: Type cannot be serialized ``` **From subscriber code (works):** ```apex // Inside your class that extends btcdev.QueueableJob JSON.serialize(this); // works fine ``` ### 2. Type Resolution Context `Type.forName()` resolves types relative to the **calling code's namespace**. When the package code tries to find your subscriber class, it looks in the `btcdev` namespace where your class doesn't exist. **From package code:** ```apex // Inside btcdev.QueueableJob Type.forName('MyJob'); // returns null (looks for btcdev.MyJob) ``` **From subscriber code:** ```apex // Inside your class Type.forName('MyJob'); // returns MyJob.class ``` ## Why One Base Class Is Enough `cloneForDeepCopy()` is `virtual`, so you can override it anywhere in the hierarchy. Both platform rules above are about **where the code runs**, not which class it belongs to. Put the override on a base class in your namespace and every subclass inherits a working deep clone, because the serialization and the `Type.forName` both happen in your namespace. `Type.forName(this.className)` is what removes the per-job part. `className` already holds the runtime class name, so the base class resolves whichever subclass it was called on. ```apex btcdev.Async.queueable(new AccountProcessorJob()) .deepClone() .enqueue(); ``` ## When Do I Need This? | Scenario | Override needed? | |----------|:---:| | Deployed without namespace (Deploy button / `sf deploy`) | No | | Installed as package, using `.deepClone()` | **Yes** | | Installed as package, NOT using `.deepClone()` | No | ## Error Messages A failed deep clone always names the cause first, then what to do about it. Forgetting the override in a packaged org looks like this: ``` deepClone() failed for the job "MyJob": System.JSONException: Type cannot be serialized Async Lib is installed as a namespaced package, so it cannot serialize your class. Override cloneForDeepCopy() in your QueueableJob subclass: public override QueueableJob cloneForDeepCopy() { return (QueueableJob) JSON.deserialize(JSON.serialize(this), YourClassName.class); } ``` The namespace is not the only thing that can stop a deep clone, and the message tells you which one you hit: | Cause | Fix | | ----- | --- | | `Type cannot be serialized` | You are on a packaged install without the override above | | `Cycle detected` | The job holds a reference back to itself. Break the cycle, or mark the field `transient` | | `Cannot deserialize JSON as abstract type` | A field is typed as an interface or abstract class, which JSON cannot rebuild. Mark it `transient`, or hold a concrete type | ## Size Limits A deep clone holds the original and the copy at the same time, so it costs roughly twice the job's size in heap. Measured, a 1 MB job needs about 2 MB. With a 6 MB synchronous heap that puts the practical ceiling at roughly **2 MB of job state**, or about 4 MB from an asynchronous caller where the limit is 12 MB. There is no separate cap on the serialized job itself: a 5 MB job enqueues and runs fine. The caller's heap is what runs out first. ## Soft Clone vs Deep Clone Recap Not sure if you need `.deepClone()` at all? See [Job Cloning](/explanations/job-cloning) for when soft clone (default) is sufficient vs when deep clone is required. --- --- url: https://async.beyondthecloud.dev/explanations/failures-and-the-chain.md --- # What a Failed Job Does to the Chain ## TL;DR Two different things happen when a job fails, and they are easy to confuse: * **Jobs that were already in the chain keep running.** A failure does not stop the chain. Use [`dependsOn(...)`](/api/queueable#dependson) to gate them. * **Jobs the failing job created during `work()` do not run**, unless its transaction committed. Same for a [`stopChain()`](/api/queueable#stopchain) or [`skipJob(...)`](/api/queueable#skipjob) it called. The rule for the second one: **if the attempt's transaction did not commit, nothing that attempt did to the chain happened.** That matches plain Apex, where a `System.enqueueJob()` inside a Queueable that throws is rolled back and the child job never runs. ## Why this needs explaining In plain Apex the answer is simple, because the platform gives it to you: ```apex public class ParentJob implements Queueable { public void execute(QueueableContext ctx) { insert new Account(Name = 'Parent'); System.enqueueJob(new ChildJob()); throw new CalloutException('boom'); } } ``` The exception aborts the transaction. The Account is rolled back and so is the enqueue, so `ChildJob` never runs. Async Lib does not call `System.enqueueJob` for you there. Inside a running job, `.chain()` and `.enqueue()` both just add to the chain **in memory**, and the chain travels to the next transaction inside the finalizer. A rollback does not touch it, because there is nothing in the database to roll back. So Async Lib has to reproduce that rollback itself, which is what it now does. ## What counts as "did not commit" Two separate things ride on this, so the table splits them: | Situation | Transaction committed | Jobs it chained inside `work()` | Its `stopChain()` / `skipJob()` | | --- | --- | --- | --- | | Job succeeds | yes | run | stands | | `work()` throws, default flags | no, the platform rolled it back | discarded | undone | | [`rollbackOnJobExecuteFail()`](/api/queueable#rollbackonjobexecutefail) | no, the DML went back to a savepoint | discarded | undone | | [`continueOnJobExecuteFail()`](/api/queueable#continueonjobexecutefail) | yes, the partial DML is committed | run | stands | | Uncatchable failure (governor `LimitException`) | no | discarded | undone | | Attempt that will be [retried](/api/queueable#retry) | not relevant, the attempt is thrown away | discarded | undone | The last two rows are the ones people get wrong. An uncatchable failure never reaches a `catch`, so `continueOnJobExecuteFail()` does not run and the transaction dies anyway. The framework reads the outcome from the finalizer, not from your flags, so this is handled correctly. A retried attempt is discarded whether or not it committed. `work()` re-runs on the next attempt and will chain the same jobs again, so keeping the first attempt's would double them. ## Worked example ```apex public class ImportJob extends QueueableJob { public override void work() { insert new ImportBatch__c(Status__c = 'Running'); Async.queueable(new NotifyJob()).chain(); callTheApiThatIsDown(); } } ``` ```apex Async.queueable(new ImportJob()).chain(new CleanupJob()).enqueue(); ``` `callTheApiThatIsDown()` throws. * `ImportBatch__c` is rolled back by the platform. * `NotifyJob` does **not** run. `ImportJob` created it during the attempt that died, so it goes with it. * `CleanupJob` **does** run. It was in the chain before `ImportJob` started, so it is not `ImportJob`'s to cancel. Add `.continueOnJobExecuteFail()` to `ImportJob` and both the `ImportBatch__c` row and `NotifyJob` survive, because now the transaction commits. ## Attached finalizers always survive [`attachFinalizer()`](/api/queueable#attachfinalizer) is the exception, and it mirrors the platform: `System.attachFinalizer` survives an unhandled exception, `System.enqueueJob` does not. ```apex public class ImportJob extends QueueableJob { public override void work() { Async.queueable(new AlertOpsFinalizer()).attachFinalizer(); callTheApiThatIsDown(); } } ``` `AlertOpsFinalizer` runs. Reacting to the failure is the whole point of a finalizer, so it would be useless if the failure discarded it. ## Stopping the chain on purpose `Async.stopChain()` and `Async.skipJob(...)` follow the same rule as chaining. Called from an attempt that then dies, they are undone. That matters most with retries. Without the rule, a `stopChain()` from attempt 1 would survive into attempt 2 even though the framework threw attempt 1 away, so a job that eventually **succeeded** would still have killed everything behind it. If you want a stop to stick even though the job failed, make the job's transaction commit. Catch the exception yourself: ```apex public override void work() { try { riskyThing(); } catch (Exception ex) { Async.stopChain(); } } ``` Or stop from a finalizer, which runs in its own transaction: ```apex public class GuardFinalizer extends QueueableJob.Finalizer { public override void work() { FinalizerContext fctx = Async.getQueueableJobContext().finalizerCtx; if (fctx.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) { Async.stopChain(); } } } ``` ## When Async Lib itself fails Everything above is about your job failing. If the **library** fails while advancing the chain, that used to be invisible: a finalizer exception does not show up on the `AsyncApexJob`, which still reads `Completed`, so a chain could stop with no trace anywhere. Async Lib now writes an `AsyncResult__c` row with `Status__c = FRAMEWORK_ERROR` whenever it cannot advance a chain, and re-throws so the failure is not swallowed. `ExceptionMessage__c` carries the underlying exception, the stack trace, and where to go next: ``` Async Lib could not advance the chain: System.NullPointerException: ... Check your job and QueueableJobSetting__mdt configuration against https://async.beyondthecloud.dev first. If this looks like a library bug, report it at https://github.com/beyond-the-cloud-dev/async-lib/issues ``` ::: warning Written even when results are off This row is written regardless of `QueueableJobSetting__mdt.CreateResult__c`. Turning result creation off opts out of routine bookkeeping, not out of being told the framework broke. It is the only status that ignores that setting. `FRAMEWORK_ERROR` rows are cleaned up on the `othersOlderThanDays(...)` track, see [AsyncResult Cleanup](/explanations/asyncresult-cleanup). ::: A governor limit hit by **your job** is fully covered. It never reaches a `catch`, but the finalizer receives it and Async Lib records it like any other failure: ``` System.AsyncException :: System.LimitException: Too many SOQL queries: 201 ``` Note the recorded type is `System.AsyncException`, not `System.LimitException`. `retryOn(LimitException.class)` will therefore not match it. Since Async Lib writes the reason to `RetryHistory__c` when a type does not match, you will see why rather than wondering where the retry went. The one real gap is a governor limit hit **inside the finalizer itself**, for example by an `onFinalFailure` override that burns through queries. Apex cannot catch a `LimitException`, so there is no second finalizer to record it: the `AsyncApexJob` reads `Completed` and no row is written. Keep `onFinalFailure` cheap. ## Misconfiguration fails at enqueue, not later Configuration mistakes are reported when you enqueue, in your own transaction, rather than surfacing as a job that quietly does the wrong thing hours later. An unknown `QueueableJobSetting__mdt.BackoffStrategy__c` throws instead of silently running retries with no delay: ``` QueueableJobSetting__mdt.BackoffStrategy__c is "EXPONENTAIL" for "All", which is not a known strategy. Use one of: FIXED, EXPONENTIAL, EXPONENTIAL_JITTER. ``` `RetryableExceptions__c` is different, and deliberately so. Entries there are matched by name, and a typo would otherwise mean the job simply never retries. We cannot reject unknown names up front, because a subscriber's own exception class is not resolvable from inside the package. Instead, when a failure is not retried because its type is not in the list, the reason is written to `RetryHistory__c`: ``` AsyncTest.CustomException is not in retryOn(System.DmlException) - not retried ``` So a typo shows up on the result row rather than looking like retry silently not working. ## Nothing is recorded for a discarded job A job the framework discards produces no `AsyncResult__c` row. It never ran, and from outside the failed transaction nobody ever held its id, so there is no event to record. Salesforce does not tell you about a rolled-back `System.enqueueJob` either. What is recorded is the failure that caused it: the failing job's own `AsyncResult__c` row, with `Status__c = FAILED`. Watch for one case. With `rollbackOnJobExecuteFail()` the exception is swallowed, so the `AsyncApexJob` reads **Completed** while the jobs the attempt chained have vanished. The `AsyncResult__c` row still says `FAILED`, so check there rather than the `AsyncApexJob`. --- --- url: https://async.beyondthecloud.dev/explanations/testing-async-jobs.md --- # Testing Async Jobs ## TL;DR Testing asynchronous jobs in Salesforce presents unique challenges because `QueueableContext` and `FinalizerContext` are system-provided during runtime. AsyncMock provides mock implementations of these context interfaces, enabling you to: * Test finalizer error handling without triggering actual job failures * Test queueable job behavior with controlled context * Direct unit testing of job `work()` methods without `Test.startTest()/stopTest()` * Queue-based mock consumption for testing multiple invocations The rule is **pushed by the platform, mock it. Passed by you, swap it.** A `ChunkSource` falls in the second group, so a chunk run has no mock and needs none: hand it `ChunkSource.of(records)` or your own subclass. See [Pattern 5](#pattern-5-testing-a-chunk-job-without-a-cursor) and [Pattern 6](#pattern-6-a-custom-chunksource). The context objects inside a chunk page are still pushed, so those keep using `mockId(...)` ([Pattern 7](#pattern-7-mocking-inside-a-chunk-page)). An [`onFinalFailure`](/api/queueable#onfinalfailure) override needs neither: it runs in the finalizer, so let the job fail for real and assert what the override wrote ([Pattern 8](#pattern-8-testing-an-onfinalfailure-override)). View the full [AsyncMock API](/api/async-mock) documentation for method details. ## The Testing Challenge ### Why Standard Testing Falls Short When testing async jobs traditionally, you face these limitations: 1. **Limited Context Control**: You cannot control what `FinalizerContext` returns 2. **No Exception Simulation**: Cannot easily simulate `ParentJobResult.UNHANDLED_EXCEPTION` 3. **Integration-Only Testing**: Must use `Test.startTest()/stopTest()` for all scenarios 4. **No Multiple Invocation Testing**: Hard to test a job that handles multiple calls differently ### Traditional Approach ```apex @IsTest static void traditionalTest() { Test.startTest(); Async.queueable(new MyJob()).enqueue(); Test.stopTest(); // Can only verify end results, not intermediate states // Cannot test error handling paths // Cannot test finalizer behavior with exceptions } ``` ### The AsyncMock Solution AsyncMock provides: 1. **Mock Context Classes**: Full implementations of Salesforce context interfaces 2. **Fluent Setup API**: Easy-to-read test setup with `whenFinalizer().thenReturn()` 3. **Queue-Based Mocks**: Multiple mock responses for sequential calls 4. **Default Fallback**: Default mocks when specific mockId isn't found ## Testing Patterns ### Pattern 1: Testing Finalizer Error Handling Test how your finalizer handles job failures without actually causing a failure. ```apex public class ErrorHandlerFinalizer extends QueueableJob.Finalizer { public override void work() { FinalizerContext ctx = this.finalizerCtx; if (ctx?.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) { insert new Account( Name = 'Error Log', Description = ctx.getException()?.getMessage() ); } } } public class ParentJobWithFinalizer extends QueueableJob { private String mockId; public ParentJobWithFinalizer(String mockId) { this.mockId = mockId; } public override void work() { Async.queueable(new ErrorHandlerFinalizer()) .mockId(mockId) .attachFinalizer(); } } ``` **Test with mocked exception:** ```apex @IsTest static void shouldHandleJobFailure() { AsyncMock.whenFinalizer('error-handler') .thenThrow(new DmlException('Parent job failed')); Test.startTest(); Async.queueable(new ParentJobWithFinalizer('error-handler')).enqueue(); Test.stopTest(); Account errorLog = [SELECT Name, Description FROM Account LIMIT 1]; Assert.areEqual('Error Log', errorLog.Name); Assert.areEqual('Parent job failed', errorLog.Description); } ``` **Test with success result:** ```apex @IsTest static void shouldNotCreateLogOnSuccess() { AsyncMock.whenFinalizer('error-handler') .thenReturn(ParentJobResult.SUCCESS); Test.startTest(); Async.queueable(new ParentJobWithFinalizer('error-handler')).enqueue(); Test.stopTest(); Assert.areEqual(0, [SELECT COUNT() FROM Account]); } ``` ### Pattern 2: Direct Unit Testing Test job logic directly without `Test.startTest()/stopTest()` by injecting mock contexts. ```apex public class AccountCreatorJob extends QueueableJob { private String accountName; public AccountCreatorJob(String accountName) { this.accountName = accountName; } public override void work() { Id jobId = this.queueableCtx?.getJobId(); insert new Account(Name = accountName, Description = 'Job: ' + jobId); } } ``` **Direct test:** ```apex @IsTest static void shouldCreateAccountDirectly() { AccountCreatorJob job = new AccountCreatorJob('Direct Test'); job.queueableCtx = new AsyncMock.MockQueueableContext(); job.work(); Account acc = [SELECT Name, Description FROM Account LIMIT 1]; Assert.areEqual('Direct Test', acc.Name); Assert.isNotNull(acc.Description); } ``` **Finalizer direct test:** ```apex @IsTest static void shouldTestFinalizerDirectly() { ErrorHandlerFinalizer finalizer = new ErrorHandlerFinalizer(); finalizer.finalizerCtx = new AsyncMock.MockFinalizerContext() .setResult(ParentJobResult.UNHANDLED_EXCEPTION) .setException(new DmlException('Direct test error')); finalizer.work(); Account errorLog = [SELECT Name, Description FROM Account LIMIT 1]; Assert.areEqual('Error Log', errorLog.Name); Assert.areEqual('Direct test error', errorLog.Description); } ``` ### Pattern 3: Multiple Invocation Testing Test jobs that should behave differently on sequential calls using queue-based mocks. ```apex @IsTest static void shouldHandleMultipleInvocations() { AsyncMock.whenFinalizer('multi-test') .thenReturn(ParentJobResult.SUCCESS) .thenThrow(new DmlException('Second call failed')) .thenReturn(ParentJobResult.SUCCESS); Test.startTest(); Async.queueable(new ParentJobWithFinalizer('multi-test')).enqueue(); Async.queueable(new ParentJobWithFinalizer('multi-test')).enqueue(); Async.queueable(new ParentJobWithFinalizer('multi-test')).enqueue(); Test.stopTest(); // Only the second call created an error log Assert.areEqual(1, [SELECT COUNT() FROM Account]); Assert.areEqual( 'Second call failed', [SELECT Description FROM Account LIMIT 1].Description ); } ``` ### Pattern 4: Default Mock Fallback Use default mocks for jobs without specific mock IDs. ```apex @IsTest static void shouldUseDefaultMock() { AsyncMock.whenFinalizerDefault() .thenReturn(ParentJobResult.SUCCESS); Test.startTest(); // All these jobs use the default mock Async.queueable(new ParentJobWithFinalizer('job-1')).enqueue(); Async.queueable(new ParentJobWithFinalizer('job-2')).enqueue(); Test.stopTest(); Assert.areEqual(0, [SELECT COUNT() FROM Account]); } ``` **Combining specific and default mocks:** ```apex @IsTest static void shouldFallbackToDefault() { AsyncMock.whenFinalizerDefault().thenReturn(ParentJobResult.SUCCESS); AsyncMock.whenFinalizer('special').thenThrow(new DmlException('Error')); // First call uses specific mock, then falls back to default FinalizerContext ctx1 = AsyncMock.getFinalizerContext('special'); FinalizerContext ctx2 = AsyncMock.getFinalizerContext('special'); Assert.areEqual(ParentJobResult.UNHANDLED_EXCEPTION, ctx1.getResult()); Assert.areEqual(ParentJobResult.SUCCESS, ctx2.getResult()); // Falls back to default } ``` ### Pattern 5: Testing a Chunk Job Without a Cursor `ChunkSource.of(...)` is the in-memory source and doubles as the test fake, so a chunk job can be exercised without inserting data and opening a live cursor. Swap it for `ChunkSource.query(...)` in production code only. ```apex @IsTest static void shouldProcessEveryPage() { List accounts = new List(); for (Integer i = 0; i < 6; i++) { accounts.add(new Account(Name = 'Test ' + i)); } insert accounts; Test.startTest(); Async.chunk(new AccountRecalcJob(), ChunkSource.of(accounts)) .chunkSize(2) .enqueue(); Test.stopTest(); Assert.areEqual(6, [SELECT COUNT() FROM Account WHERE Description = 'Recalculated']); } ``` To assert a single page in isolation, call `work(chunk)` directly with the records you care about. No enqueue, no chain, no async boundary: ```apex @IsTest static void shouldRecalcOnePage() { new AccountRecalcJob().work(accounts); Assert.areEqual(2, [SELECT COUNT() FROM Account WHERE Description = 'Recalculated']); } ``` **Why there is no ChunkSource mock** A chunk page is an ordinary job in the chain, so every pattern above already applies to a run. `AsyncMock` handles what the platform **pushes** into a job: the contexts you cannot construct, and the parent outcome a finalizer reacts to. A `ChunkSource` is **passed**, by you, at the call site. Swapping it is the test seam, so a mock registry would only add a second way to do the same thing, and a worse one: it changes what production code does behind its back. The rule of thumb: **pushed by the platform, mock it. Passed by you, swap it.** Design your service so the swap is possible: ```apex // Hard to test: the source is welded in public static void recalcAll() { Async.chunk(new AccountRecalcJob(), ChunkSource.query('SELECT Id FROM Account')).enqueue(); } // Easy to test: the caller decides public static void recalcAll(ChunkSource source) { Async.chunk(new AccountRecalcJob(), source).enqueue(); } ``` **Testing a cursor run** Nothing is blocked. `Database.getCursor(...)` runs inside a test against data the test inserted, and the cursor survives every hop of the run exactly as it does in production: ```apex @IsTest static void shouldPageACursor() { insert accounts; // 6 of them Test.startTest(); Async.chunk(new AccountRecalcJob(), ChunkSource.query('SELECT Id FROM Account')) .chunkSize(2) .enqueue(); Test.stopTest(); Assert.areEqual(6, [SELECT COUNT() FROM Account WHERE Description = 'Recalculated']); } ``` Use the real query whenever the query is the thing you want to cover. A test built on `ChunkSource.of(...)` never executes your SOQL, so a bad field or a wrong WHERE clause survives it. What no test can reach: cursor expiry after 2 days, the daily cursor allocation, and the 2,000 record `fetch()` ceiling. Faking those would test the fake. ### Pattern 6: A Custom ChunkSource `ChunkSource` is an abstract class, so a test can supply one that fabricates records instead of inserting them. This is how you page 100,000 records without a single DML statement: ```apex private class SyntheticChunkSource extends ChunkSource { private Integer total; public SyntheticChunkSource(Integer total) { this.total = total; } public override Integer getNumRecords() { return total; } public override List fetch(Integer position, Integer count) { List page = new List(); for (Integer i = position; i < Math.min(position + count, total); i++) { page.add(new Account(Name = 'Synthetic ' + i)); } return page; } } ``` The same trick covers a broken source. Throw from `fetch(...)` and the page fails and retries like any other page failure: ```apex private class ExplodingChunkSource extends ChunkSource { public override Integer getNumRecords() { return 4; } public override List fetch(Integer position, Integer count) { throw new CalloutException('source unavailable'); } } ``` ```apex Async.chunk(new AccountRecalcJob(), new ExplodingChunkSource()) .chunkSize(2) .retry(1) .enqueue(); // AsyncResult__c: Status__c = FAILED, RetryAttempts__c = 1 ``` ### Pattern 7: Mocking Inside a Chunk Page A chunk page is an ordinary job in the chain, so `mockId(...)` works on a chunk run exactly as it does on a queueable: ```apex @IsTest static void shouldReadMockedContext() { AsyncMock.whenQueueable('chunk-page') .thenReturn(new AsyncMock.MockQueueableContext().setJobId(mockJobId)); Test.startTest(); Async.chunk(new AccountRecalcJob(), ChunkSource.of(accounts)) .chunkSize(2) .mockId('chunk-page') .enqueue(); Test.stopTest(); } ``` The finalizer story carries over too. Attach a finalizer inside `work(chunk)` and you can tell it its page blew up, without engineering a page that actually fails: ```apex public class AccountRecalcJob extends ChunkJob { public override void work(List chunk) { Async.queueable(new ErrorHandlerFinalizer()).mockId('page-error-handler').attachFinalizer(); // ... work on chunk ... } } ``` ```apex @IsTest static void shouldHandleAPageFailure() { AsyncMock.whenFinalizer('page-error-handler').thenThrow(new DmlException('Page blew up')); Test.startTest(); Async.chunk(new AccountRecalcJob(), ChunkSource.of(accounts)).chunkSize(2).enqueue(); Test.stopTest(); Assert.areEqual(1, [SELECT COUNT() FROM Account WHERE Name = 'Error Log']); } ``` As with any job, the `mockId` for finalizer mocking goes on the finalizer, not on the chunk job. To make the run itself take its failure path (retry, `stopRemainingChunksOnFailure`, the summary result, a skipped dependent job), use `thenThrow`. Every page consumes one entry from the mock queue, so the queue decides which page fails: ```apex @IsTest static void shouldHaltAfterTheSecondPage() { AsyncMock.whenQueueable('recalc-run') .thenReturn(new AsyncMock.MockQueueableContext()) // page 1 succeeds .thenThrow(new CalloutException('boom')); // page 2 fails Test.startTest(); Async.chunk(new AccountRecalcJob(), ChunkSource.of(accounts)) .chunkSize(2) .mockId('recalc-run') .stopRemainingChunksOnFailure() .enqueue(); Test.stopTest(); Assert.areEqual(2, [SELECT COUNT() FROM Account WHERE Description = 'Recalculated']); } ``` Throwing from `work(chunk)` or from a `ChunkSource` subclass (Pattern 6) is still the right tool when the failure depends on the data itself. ### Pattern 8: Testing an onFinalFailure Override [`onFinalFailure`](/api/queueable#onfinalfailure) runs inside the framework's finalizer, so an end-to-end test is the honest way to cover it: enqueue a job that fails, then assert on what the override wrote. The value worth asserting is `retryOutcome`, because it is the part a test can get wrong silently. Configure the retry policy three different ways and the same job produces three different outcomes. ```apex private class FailingJob extends QueueableJob { public override void work() { throw new CalloutException('service down'); } public override void onFinalFailure(Async.FailureContext failureCtx) { insert new IntegrationLog__c( Outcome__c = failureCtx.retryOutcome.name(), Attempts__c = failureCtx.retryAttempt ); } } @IsTest static void shouldReportExhaustedAfterRetrying() { Test.startTest(); Async.queueable(new FailingJob()).continueOnJobExecuteFail().retry(2).enqueue(); Test.stopTest(); List logs = [SELECT Outcome__c, Attempts__c FROM IntegrationLog__c]; Assert.areEqual(1, logs.size(), 'The hook fires once, not once per attempt.'); Assert.areEqual('EXHAUSTED', logs[0].Outcome__c); Assert.areEqual(2, logs[0].Attempts__c); } @IsTest static void shouldReportNotRetryableWhenTypeIsExcluded() { Test.startTest(); Async.queueable(new FailingJob()) .continueOnJobExecuteFail() .retry(2) .retryOn(DmlException.class) .enqueue(); Test.stopTest(); Assert.areEqual('NOT_RETRYABLE', [SELECT Outcome__c FROM IntegrationLog__c].Outcome__c); } ``` Drop `retry(2)` entirely and the same job reports `NOT_CONFIGURED`. Two traps worth knowing: * **Asserting the hook did not fire proves very little on its own.** A test that only checks "no log row" passes just as happily when the hook is broken. Pair every negative case with a positive one. * **A throwing override is swallowed by design**, so a bug in your `onFinalFailure` will not fail the test. The framework records it in `RetryHistory__c` instead. If the override is doing real work, assert on its output rather than trusting that the chain completed. ## Best Practices ### 1. Use mockId for Targeted Mocking Always use meaningful mock IDs that describe the test scenario: ```apex // Good AsyncMock.whenFinalizer('payment-error-handler').thenThrow(new PaymentException()); AsyncMock.whenFinalizer('notification-sender').thenReturn(ParentJobResult.SUCCESS); // Avoid generic IDs AsyncMock.whenFinalizer('test').thenThrow(new Exception()); ``` ### 2. Reset Mocks When Needed If running multiple tests that share mock state, reset between tests: ```apex @IsTest static void testOne() { AsyncMock.whenFinalizer('test').thenReturn(ParentJobResult.SUCCESS); // ... test code } @IsTest static void testTwo() { AsyncMock.reset(); // Clean slate AsyncMock.whenFinalizer('test').thenThrow(new DmlException()); // ... test code } ``` ### 3. Prefer Direct Testing When Possible Direct testing is faster and more focused: ```apex // Faster - direct unit test @IsTest static void directTest() { MyJob job = new MyJob(); job.queueableCtx = new AsyncMock.MockQueueableContext(); job.work(); // Assert results } // Slower - full integration test @IsTest static void integrationTest() { Test.startTest(); Async.queueable(new MyJob()).enqueue(); Test.stopTest(); // Assert results } ``` ### 4. Test Both Success and Failure Paths Always verify your jobs handle both outcomes: ```apex @IsTest static void shouldHandleSuccess() { AsyncMock.whenFinalizer('handler').thenReturn(ParentJobResult.SUCCESS); // Test success path } @IsTest static void shouldHandleFailure() { AsyncMock.whenFinalizer('handler').thenThrow(new DmlException('Failed')); // Test error handling path } ``` ## Summary AsyncMock enables comprehensive testing of async jobs by providing mock implementations of Salesforce context interfaces. Key capabilities: | Feature | Benefit | |---------|---------| | Mock contexts | Control job behavior in tests | | Queue-based mocks | Test sequential call patterns | | Default fallback | Simplify multi-job test setup | | Direct testing | Faster, focused unit tests | Use these patterns to ensure your async jobs are thoroughly tested and resilient to both success and failure scenarios. --- --- url: https://async.beyondthecloud.dev/explanations/asyncresult-cleanup.md --- # AsyncResult Cleanup ## TL;DR `AsyncResult__c` records accumulate with every tracked job and are never deleted on their own. Async Lib ships a dormant `AsyncResultCleanupBatch`. Schedule it once with explicit retention and old records get deleted daily: ```apex Async.batchable( new AsyncResultCleanupBatch() .failedOlderThanDays(90) .othersOlderThanDays(30) ) .asSchedulable() .name('AsyncResult Cleanup') .cronExpression(new CronBuilder().everyDay(3, 0)) .schedule(); ``` Nothing runs until you schedule it, and there are no default retention values. Every track you want cleaned must be configured explicitly. ## Two Retention Tracks Failed results are usually the ones you keep around for debugging, so they get their own retention, independent from everything else. | Track | Matches `Status__c` | Builder method | | -------- | ------------------------------------------------------------------------------------------ | ---------------------------------- | | Failed | `FAILED` | `failedOlderThanDays(Integer days)` | | The rest | `COMPLETED`, `SKIPPED_DEPENDENCY`, `SKIPPED_CHAIN_STOPPED`, `SKIPPED_CHUNK_STOPPED`, `SKIPPED_EXPLICIT`, `SKIPPED_DISABLED`, `FRAMEWORK_ERROR` | `othersOlderThanDays(Integer days)` | Set one track, the other, or both. A track without a configured retention is **never touched**: ```apex // Delete failed results older than 90 days, keep everything else forever. new AsyncResultCleanupBatch().failedOlderThanDays(90); // Delete completed and skipped results older than 30 days, keep failed forever. new AsyncResultCleanupBatch().othersOlderThanDays(30); // Keep failed results three times longer than the rest. new AsyncResultCleanupBatch().failedOlderThanDays(90).othersOlderThanDays(30); ``` ## Rules * Retention days must be greater than zero. `failedOlderThanDays(0)` or a `null` value throws an `IllegalArgumentException` immediately. * At least one track must be configured. Running the batch with none throws when the batch starts. * Use a retention of at least a few days so results from long-running or delayed chains are never deleted while their chain is still being reconciled. ## Running It Once, Ad Hoc The same batch works without scheduling, for a one-off purge: ```apex Async.batchable(new AsyncResultCleanupBatch().othersOlderThanDays(30)).execute(); ``` For very large backlogs, raise the batch scope: ```apex Async.batchable(new AsyncResultCleanupBatch().othersOlderThanDays(30)) .scopeSize(2000) .execute(); ``` --- --- url: >- https://async.beyondthecloud.dev/explanations/expected-exceptions-in-debug-logs.md --- # Expected Exceptions in Debug Logs ## TL;DR If your debug logs show an entry like this while using Async Lib: ``` System.TypeException: Invalid conversion from runtime type MyJob to Datetime ``` it is **expected and harmless**. The framework throws and catches this exception on purpose to detect your job's class name. It never reaches your code and does not affect job processing. No action is needed. ## Why Does It Happen? Apex has no reflection API that returns the class name of an object instance. The framework needs the full class name (including the namespace and the outer class for inner classes) to: * build the job's unique name, * match `QueueableJobSetting__mdt` records to jobs, * resolve the job type again during [deep clone](/explanations/job-cloning). The only reliable way to get the name is a well-known Apex workaround: cast the instance to an incompatible type, catch the `TypeException`, and read the class name out of the exception message. This is what `QueueableJob.getFullClassName()` does: ```apex private String getFullClassName(Object job) { String result; try { DateTime typeCheck = (DateTime) job; } catch (System.TypeException expectedTypeException) { String message = expectedTypeException.getMessage() .substringAfter('Invalid conversion from runtime type '); result = message.substringBefore(' to Datetime'); } return result; } ``` The cast always fails, the catch block always runs, and the class name comes out of the message. Alternatives like `String.valueOf(instance)` are not reliable: they break when a class overrides `toString()` and do not always include the namespace or the outer class name. ## Why Does a Caught Exception Show Up in the Log? Salesforce writes an `EXCEPTION_THROWN` entry to the debug log for every thrown exception, even when it is caught immediately. The log line does not mean the exception escaped. If a job had actually failed, you would see it as a `FATAL_ERROR` log entry and in the job's `AsyncResult__c` record (`Status__c`, `ExceptionType__c`, `ExceptionMessage__c`). ## When Will I See It? Whenever the framework reads a job's class name: at enqueue time, when resolving `QueueableJobSetting__mdt` settings, and when recording results. Several entries per transaction are normal, especially for chains with multiple jobs and finalizers.