> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fusioncore.us/llms.txt
> Use this file to discover all available pages before exploring further.

# Async Job Coordinator Example

> Build and run a three-step Job Group that rates Prospect Accounts, creates Contacts, and stamps summaries, including an error and a retry.

This example builds a three-step Job Group end to end. It shows how steps depend on each other, how an error appears, and how a rerun succeeds after you fix the data.

## Prerequisites

* You have the **FC Platform - Manage Async Jobs** permission set.
* You can create Accounts and Contacts in the org.
* You can deploy Apex classes to your org (Setup, an IDE, or a deployment).
* You understand the basics from [Async Job Coordinator Overview](https://docs.fusioncore.us/end-user-guides/platform-setup/async-job-coordinator/index.md).

## The Goal

The Job Group is named **Prospect Account Enrichment**. Its description is "Rates Prospect Accounts, creates a primary Contact for Hot prospects, and stamps a summary on each Account."

| **Step** | **Definition Name** | **Apex Class** | **Await** | **What it does** |
| - | - | - | - | - |
| 1 | Update Account Rating | `AccountRatingJob` | On | Sets **Rating** on each Prospect Account to Hot if **Annual Revenue** is 1,000,000 or more, otherwise Warm. |
| 2 | Create Primary Contacts | `PrimaryContactJob` | On | For each Hot Prospect with no Contacts, creates a Contact named "Primary Contact" with the email `info@` plus the Account's website domain. An Account with no **Website** is reported as an error. |
| 3 | Stamp Account Descriptions | `AccountDescriptionJob` | Off | Sets **Description** to `Rating: <Rating>. Contacts: <n>.` |

All three steps use a **Batch Size** of 200 and leave **Parallel** off.

### Why Await Is On for Steps 1 and 2

Step 2 only finds Hot Accounts, so it needs the ratings from step 1. Step 3 counts Contacts, so it needs the Contacts from step 2. Sequence alone does not make a step wait, so **Await** on steps 1 and 2 holds the next step until the previous one finishes. Step 3 has no later step, so it does not need **Await**.

## Demo Data

Create these Accounts with **Type** set to **Prospect**:

| **Account Name** | **Annual Revenue** | **Website** |
| - | - | - |
| The Redwood Foundation | 2,500,000 | [www.redwood.example.com](http://www.redwood.example.com) |
| Harbor Light Society | 1,200,000 | (blank) |
| Maple Grove Association | 400,000 | [www.maplegrove.example.com](http://www.maplegrove.example.com) |
| Cedar Ridge Alliance | 1,800,000 | [https://cedarridge.example.com](https://cedarridge.example.com) |

Harbor Light Society has no **Website** on purpose. It makes step 2 fail so you can see how errors appear.

## The Apex Classes

Each class implements `FCORE_BASE.JobExecutor`, is declared `global`, and uses `with sharing`. Each one implements both the regular and retry methods. See [For Developers](https://docs.fusioncore.us/end-user-guides/platform-setup/async-job-coordinator/index.md#for-developers) for the interface.

### Step 1: AccountRatingJob

This class reports a failed record through `recordErrors`. It updates with `allOrNone` set to `false`, so one bad record does not fail the whole batch. In `executeRetry`, it reports only successes.

```java theme={null}
/**
 * Step 1 of the example Job Group: rates every Prospect Account by Annual Revenue.
 */
global with sharing class AccountRatingJob implements FCORE_BASE.JobExecutor {

    global Database.QueryLocator getQueryLocator(FCORE_BASE.JobRunStageContext context) {
        return Database.getQueryLocator([SELECT Id, AnnualRevenue FROM Account WHERE Type = 'Prospect']);
    }

    global Iterable<Object> getIterable(FCORE_BASE.JobRunStageContext context) {
        return null; // getQueryLocator is used instead
    }

    global FCORE_BASE.JobRunStageResult execute(List<Object> scope, FCORE_BASE.JobRunStageContext context) {
        FCORE_BASE.JobRunStageResult result = new FCORE_BASE.JobRunStageResult(context);
        List<Account> accounts = (List<Account>) scope;
        for (Account account : accounts) {
            account.Rating = account.AnnualRevenue >= 1000000 ? 'Hot' : 'Warm';
        }
        // allOrNone = false: one bad record does not fail the whole chunk
        List<Database.SaveResult> saveResults = Database.update(accounts, false);
        for (Integer i = 0; i < saveResults.size(); i++) {
            if (!saveResults[i].isSuccess()) {
                FCORE_BASE.JobRunStageResult.RecordError error = new FCORE_BASE.JobRunStageResult.RecordError();
                error.recordId = accounts[i].Id;
                error.errorMessage = saveResults[i].getErrors()[0].getMessage();
                result.recordErrors.add(error);
            }
        }
        return result;
    }

    global Database.QueryLocator getRetryQueryLocator(FCORE_BASE.JobRunStageContext context) {
        // Context 1 of a retry stage holds the failed record IDs as a JSON list
        Set<Id> retryIds = String.isBlank(context.stage.FCORE_BASE__Context_1__c)
            ? new Set<Id>()
            : (Set<Id>) JSON.deserialize(context.stage.FCORE_BASE__Context_1__c, Set<Id>.class);
        return Database.getQueryLocator([SELECT Id, AnnualRevenue FROM Account WHERE Id IN :retryIds]);
    }

    global Iterable<Object> getRetryIterable(FCORE_BASE.JobRunStageContext context) {
        return null;
    }

    global FCORE_BASE.JobRunStageResult executeRetry(List<Object> scope, FCORE_BASE.JobRunStageContext context) {
        FCORE_BASE.JobRunStageResult result = new FCORE_BASE.JobRunStageResult(context);
        List<Account> accounts = (List<Account>) scope;
        for (Account account : accounts) {
            account.Rating = account.AnnualRevenue >= 1000000 ? 'Hot' : 'Warm';
        }
        List<Database.SaveResult> saveResults = Database.update(accounts, false);
        for (Integer i = 0; i < saveResults.size(); i++) {
            // Report only successes; the original Job Error stays for records that fail again
            if (saveResults[i].isSuccess()) {
                result.successfullyRetriedRecordIds.add(accounts[i].Id);
            }
        }
        return result;
    }
}
```

### Step 2: PrimaryContactJob

This class reports an Account with no **Website** as a record error. That error is what triggers the retry.

```java theme={null}
/**
 * Step 2 of the example Job Group: creates a primary Contact for each Hot Prospect Account
 * that has no Contacts yet. The Contact's email uses the Account's Website domain.
 */
global with sharing class PrimaryContactJob implements FCORE_BASE.JobExecutor {

    global Database.QueryLocator getQueryLocator(FCORE_BASE.JobRunStageContext context) {
        return Database.getQueryLocator([
            SELECT Id, Name, Website FROM Account
            WHERE Type = 'Prospect' AND Rating = 'Hot' AND Id NOT IN (SELECT AccountId FROM Contact)
        ]);
    }

    global Iterable<Object> getIterable(FCORE_BASE.JobRunStageContext context) {
        return null;
    }

    global FCORE_BASE.JobRunStageResult execute(List<Object> scope, FCORE_BASE.JobRunStageContext context) {
        FCORE_BASE.JobRunStageResult result = new FCORE_BASE.JobRunStageResult(context);
        result.recordErrors.addAll(createContacts((List<Account>) scope));
        return result;
    }

    global Database.QueryLocator getRetryQueryLocator(FCORE_BASE.JobRunStageContext context) {
        Set<Id> retryIds = String.isBlank(context.stage.FCORE_BASE__Context_1__c)
            ? new Set<Id>()
            : (Set<Id>) JSON.deserialize(context.stage.FCORE_BASE__Context_1__c, Set<Id>.class);
        return Database.getQueryLocator([SELECT Id, Name, Website FROM Account WHERE Id IN :retryIds]);
    }

    global Iterable<Object> getRetryIterable(FCORE_BASE.JobRunStageContext context) {
        return null;
    }

    global FCORE_BASE.JobRunStageResult executeRetry(List<Object> scope, FCORE_BASE.JobRunStageContext context) {
        FCORE_BASE.JobRunStageResult result = new FCORE_BASE.JobRunStageResult(context);
        Set<Id> failedIds = new Set<Id>();
        for (FCORE_BASE.JobRunStageResult.RecordError error : createContacts((List<Account>) scope)) {
            failedIds.add(error.recordId);
        }
        for (Account account : (List<Account>) scope) {
            if (!failedIds.contains(account.Id)) {
                result.successfullyRetriedRecordIds.add(account.Id);
            }
        }
        return result;
    }

    private List<FCORE_BASE.JobRunStageResult.RecordError> createContacts(List<Account> accounts) {
        List<FCORE_BASE.JobRunStageResult.RecordError> errors = new List<FCORE_BASE.JobRunStageResult.RecordError>();
        List<Contact> contacts = new List<Contact>();
        for (Account account : accounts) {
            if (String.isBlank(account.Website)) {
                FCORE_BASE.JobRunStageResult.RecordError error = new FCORE_BASE.JobRunStageResult.RecordError();
                error.recordId = account.Id;
                error.errorMessage = 'Website is required to create the primary Contact.';
                errors.add(error);
                continue;
            }
            String domain = account.Website.removeStartIgnoreCase('https://').removeStartIgnoreCase('http://')
                .removeStartIgnoreCase('www.').substringBefore('/');
            contacts.add(new Contact(
                AccountId = account.Id,
                FirstName = 'Primary',
                LastName = 'Contact',
                Email = 'info@' + domain
            ));
        }
        insert contacts;
        return errors;
    }
}
```

### Step 3: AccountDescriptionJob

This class returns `null` from `execute` because there is nothing for the coordinator to post-process.

```java theme={null}
/**
 * Step 3 of the example Job Group: stamps each Prospect Account's Description
 * with its Rating and Contact count.
 */
global with sharing class AccountDescriptionJob implements FCORE_BASE.JobExecutor {

    global Database.QueryLocator getQueryLocator(FCORE_BASE.JobRunStageContext context) {
        return Database.getQueryLocator([
            SELECT Id, Rating, (SELECT Id FROM Contacts) FROM Account WHERE Type = 'Prospect'
        ]);
    }

    global Iterable<Object> getIterable(FCORE_BASE.JobRunStageContext context) {
        return null;
    }

    global FCORE_BASE.JobRunStageResult execute(List<Object> scope, FCORE_BASE.JobRunStageContext context) {
        stampDescriptions((List<Account>) scope);
        return null; // nothing for the framework to post-process
    }

    global Database.QueryLocator getRetryQueryLocator(FCORE_BASE.JobRunStageContext context) {
        Set<Id> retryIds = String.isBlank(context.stage.FCORE_BASE__Context_1__c)
            ? new Set<Id>()
            : (Set<Id>) JSON.deserialize(context.stage.FCORE_BASE__Context_1__c, Set<Id>.class);
        return Database.getQueryLocator([
            SELECT Id, Rating, (SELECT Id FROM Contacts) FROM Account WHERE Id IN :retryIds
        ]);
    }

    global Iterable<Object> getRetryIterable(FCORE_BASE.JobRunStageContext context) {
        return null;
    }

    global FCORE_BASE.JobRunStageResult executeRetry(List<Object> scope, FCORE_BASE.JobRunStageContext context) {
        FCORE_BASE.JobRunStageResult result = new FCORE_BASE.JobRunStageResult(context);
        for (Account account : stampDescriptions((List<Account>) scope)) {
            result.successfullyRetriedRecordIds.add(account.Id);
        }
        return result;
    }

    private List<Account> stampDescriptions(List<Account> accounts) {
        for (Account account : accounts) {
            account.Description = 'Rating: ' + account.Rating + '. Contacts: ' + account.Contacts.size() + '.';
        }
        update accounts;
        return accounts;
    }
}
```

> [accordion] Test class
>
> Deploying to production requires test coverage. This class covers all three steps and the retry methods.
>
> ```apex theme={null}
> @IsTest
> private class AsyncJobExampleTest {
>
>     @IsTest
>     static void runsAllThreeSteps() {
>         Account withWebsite = new Account(Name = 'The Redwood Foundation', Type = 'Prospect', AnnualRevenue = 2000000, Website = 'www.example.com');
>         Account withoutWebsite = new Account(Name = 'Harbor Light Society', Type = 'Prospect', AnnualRevenue = 1500000);
>         insert new List<Account>{ withWebsite, withoutWebsite };
>         FCORE_BASE.JobRunStageContext context = new FCORE_BASE.JobRunStageContext();
>         context.stage = new FCORE_BASE__Job_Run_Stage__c();
>
>         Test.startTest();
>         new AccountRatingJob().execute([SELECT Id, AnnualRevenue FROM Account], context);
>         FCORE_BASE.JobRunStageResult contactResult = new PrimaryContactJob().execute(
>             [SELECT Id, Name, Website FROM Account], context);
>         new AccountDescriptionJob().execute([SELECT Id, Rating, (SELECT Id FROM Contacts) FROM Account], context);
>         Test.stopTest();
>
>         System.assertEquals(1, contactResult.recordErrors.size());
>         System.assertEquals(withoutWebsite.Id, contactResult.recordErrors[0].recordId);
>         System.assertEquals('info@example.com', [SELECT Email FROM Contact WHERE AccountId = :withWebsite.Id].Email);
>         System.assertEquals('Rating: Hot. Contacts: 1.', [SELECT Description FROM Account WHERE Id = :withWebsite.Id].Description);
>         System.assertEquals('Rating: Hot. Contacts: 0.', [SELECT Description FROM Account WHERE Id = :withoutWebsite.Id].Description);
>     }
>
>     @IsTest
>     static void retriesOnlyTheFailedRecords() {
>         Account fixed = new Account(Name = 'Harbor Light Society', Type = 'Prospect', AnnualRevenue = 1500000, Rating = 'Hot', Website = 'https://www.example.org/about');
>         Account stillBroken = new Account(Name = 'Cedar Ridge Alliance', Type = 'Prospect', AnnualRevenue = 1200000, Rating = 'Hot');
>         insert new List<Account>{ fixed, stillBroken };
>         FCORE_BASE.JobRunStageContext context = new FCORE_BASE.JobRunStageContext();
>         context.stage = new FCORE_BASE__Job_Run_Stage__c(
>             FCORE_BASE__Is_Retry__c = true,
>             FCORE_BASE__Context_1__c = JSON.serialize(new List<Id>{ fixed.Id, stillBroken.Id })
>         );
>         AccountRatingJob ratingJob = new AccountRatingJob();
>         PrimaryContactJob contactJob = new PrimaryContactJob();
>         AccountDescriptionJob descriptionJob = new AccountDescriptionJob();
>
>         Test.startTest();
>         System.assertEquals(null, ratingJob.getIterable(context));
>         System.assertEquals(null, ratingJob.getRetryIterable(context));
>         System.assertEquals(null, contactJob.getIterable(context));
>         System.assertEquals(null, contactJob.getRetryIterable(context));
>         System.assertEquals(null, descriptionJob.getIterable(context));
>         System.assertEquals(null, descriptionJob.getRetryIterable(context));
>         System.assertNotEquals(null, ratingJob.getQueryLocator(context));
>         System.assertNotEquals(null, contactJob.getQueryLocator(context));
>         System.assertNotEquals(null, descriptionJob.getQueryLocator(context));
>
>         System.assertNotEquals(null, ratingJob.getRetryQueryLocator(context));
>         System.assertNotEquals(null, contactJob.getRetryQueryLocator(context));
>         System.assertNotEquals(null, descriptionJob.getRetryQueryLocator(context));
>
>         FCORE_BASE.JobRunStageResult ratingResult = ratingJob.executeRetry(
>             [SELECT Id, AnnualRevenue FROM Account], context);
>         FCORE_BASE.JobRunStageResult contactResult = contactJob.executeRetry(
>             [SELECT Id, Name, Website FROM Account], context);
>         FCORE_BASE.JobRunStageResult descriptionResult = descriptionJob.executeRetry(
>             [SELECT Id, Rating, (SELECT Id FROM Contacts) FROM Account], context);
>         Test.stopTest();
>
>         System.assertEquals(2, ratingResult.successfullyRetriedRecordIds.size());
>         System.assertEquals(new Set<Id>{ fixed.Id }, contactResult.successfullyRetriedRecordIds);
>         System.assertEquals(2, descriptionResult.successfullyRetriedRecordIds.size());
>         System.assertEquals('info@example.org', [SELECT Email FROM Contact WHERE AccountId = :fixed.Id].Email);
>     }
> }
> ```

## Build the Job Group

Follow [Managing Job Groups](https://docs.fusioncore.us/end-user-guides/platform-setup/async-job-coordinator/managing-job-groups.md) with these values.

**1. Create the group**

Open the **Job Groups** tab from the **App Launcher** and click **New**. Enter the **Name** `Prospect Account Enrichment` and the description above. Leave **Version** at `1` and **Is Active** off.

**2. Add the three steps**

Click **Add New Definition** three times. Fill in the rows from the table in [The Goal](#the-goal), and turn **Await** on for steps 1 and 2.

![The Job Definitions section with Update Account Rating, Create Primary Contacts and Stamp Account Descriptions, each with its Apex class and Batch Size 200, and Await on for steps 1 and 2](https://mintcdn.com/fusionspan/XDzICb2WNqgAWbUY/images/end-user-guides/async-job-coordinator/job-group-manager-definitions.png?fit=max\&auto=format\&n=XDzICb2WNqgAWbUY\&q=85\&s=72d202ffb6e88b3e28e5d202ee15bcad)

The three example steps

**3. Save and activate**

Click **Save**. Then turn **Is Active** on, leave **Frequency** as **None** (you start the run from code), and click **Save** again.

## Run the Job Group

To start a run, either set a **Frequency** and wait for the schedule, or start one from code. To start from code, open the Developer Console, click **Debug**, then **Open Execute Anonymous Apex Window**. Run the following:

```apex theme={null}
Id jobGroupId = 'a0X...'; // copy the Id from the Job Group record's URL
FCORE_BASE.JobRunService.createAndRunJob(jobGroupId);
```

The run takes a few seconds with this demo data.

## Read the First Run

Open the Job Group and click **Last Run**.

* **Status:** Completed with Errors
* **Number Of Errors:** 1

The Job Run Stages show this:

| **Stage** | **Step** | **Status** | **Why** |
| - | - | - | - |
| First | 1, Update Account Rating | Completed Successfully | All four Accounts were rated. |
| Second | 2, Create Primary Contacts | Completed with Errors | Harbor Light Society has no **Website**. |
| Third | 3, Stamp Account Descriptions | Completed Successfully | Descriptions were stamped. |
| Fourth (retry) | 2, Create Primary Contacts | Completed Successfully | The retry ran for Harbor Light Society and failed again, but shows Completed Successfully because it logged no new error. |
| Fifth (retry) | 3, Stamp Account Descriptions | Completed Successfully | No records were forwarded from the step 2 retry, so this stage had nothing to process and completed immediately. |

The Job Error on Harbor Light Society reads **Website is required to create the primary Contact.**

![Job Run JR-000000 with Status Completed with Errors, Number Of Errors 1, one Job Error, and five Job Run Stages](https://mintcdn.com/fusionspan/XDzICb2WNqgAWbUY/images/end-user-guides/async-job-coordinator/job-run-completed-with-errors.png?fit=max\&auto=format\&n=XDzICb2WNqgAWbUY\&q=85\&s=54c4c715a298439a7c712968ea46eaed)

The first run: Completed with Errors

![Job Error JE-000000 for the Create Primary Contacts step with the message Website is required to create the primary Contact](https://mintcdn.com/fusionspan/XDzICb2WNqgAWbUY/images/end-user-guides/async-job-coordinator/job-error-record.png?fit=max\&auto=format\&n=XDzICb2WNqgAWbUY\&q=85\&s=d789936fd40c5d61855a6a7832dc0c5a)

The Job Error for Harbor Light Society

## Fix the Account and Rerun

**1. Fix the data**

Open the Harbor Light Society Account, set **Website** to `www.harborlight.example.com`, and click **Save**.

**2. Rerun**

Run the same snippet from [Run the Job Group](#run-the-job-group) again in Execute Anonymous.

## Read the Clean Result

Open the Job Group and click **Last Run**, which now points to the second run.

* **Status:** Completed Successfully
* **Number Of Errors:** 0
* **Job Run Stages:** the three regular stages only. No retry stages were needed.

Harbor Light Society now has a Contact and the Description "Rating: Hot. Contacts: 1."

## Limitations and Common Pitfalls

* **Leaving Await off on step 1 or 2.** A later step can start before the earlier one finishes and find no records to process.
* **Failing to report successes in a retry.** If `executeRetry` does not list the successful record Ids, their Job Errors stay on the run.
* **Reporting errors from `executeRetry`.** This logs additional Job Errors instead of keeping the original ones.
* **Pasting the classes without the `global` keyword.** The class does not appear in the **Apex Class** dropdown.
* **Editing the group while it is active.** Deactivate and save first. See [Managing Job Groups](https://docs.fusioncore.us/end-user-guides/platform-setup/async-job-coordinator/managing-job-groups.md).

## Related Features

> [card] [Managing Job Groups](https://docs.fusioncore.us/end-user-guides/platform-setup/async-job-coordinator/managing-job-groups.md)
>
> Create, schedule, and change Job Groups.

> [card] [Monitoring Job Runs](https://docs.fusioncore.us/end-user-guides/platform-setup/async-job-coordinator/monitoring-job-runs.md)
>
> Read statuses, stages, and errors.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.