Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>54.0</apiVersion>
<apiVersion>65.0</apiVersion>
<status>Active</status>
</ApexClass>
33 changes: 27 additions & 6 deletions force-app/main/default/classes/queue/QueueableJob.cls
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,34 @@ public abstract class QueueableJob implements Queueable, Comparable {

public QueueableJob cloneJob() {
if (deepClone) {
return (QueueableJob) JSON.deserialize(
JSON.serialize(this),
Type.forName(this.className)
);
} else {
return this.clone();
try {
return cloneForDeepCopy();
} catch (JSONException e) {
throw new IllegalArgumentException(
'deepClone() failed for the job "' + className + '". ' +
'When using a namespaced package, override cloneForDeepCopy() in your QueueableJob subclass: ' +
'public override QueueableJob cloneForDeepCopy() { ' +
'return (QueueableJob) JSON.deserialize(JSON.serialize(this), YourClassName.class); }'
);
}
}
return this.clone();
}

public virtual QueueableJob cloneForDeepCopy() {
String fullName = this.className;
String namespace = null;
String typeName = fullName;

if (fullName.contains('.')) {
namespace = fullName.substringBefore('.');
typeName = fullName.substringAfter('.');
}

return (QueueableJob) JSON.deserialize(
JSON.serialize(this),
Type.forName(namespace, typeName)
);
}

private String getFullClassName(Object job) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>54.0</apiVersion>
<apiVersion>65.0</apiVersion>
<status>Active</status>
</ApexClass>
57 changes: 57 additions & 0 deletions scripts/create-unlocked-package-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/bin/bash
set -e

if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Error: Working tree is not clean. Commit or stash changes before running this script."
exit 1
fi

PACKAGE_CREATED=false
cleanup() {
if [ "$PACKAGE_CREATED" = true ]; then
echo "Staging sfdx-project.json with new version alias..."
git add sfdx-project.json
echo "Reverting Apex changes..."
git checkout -- .
else
echo "Package creation failed. Reverting all changes..."
git checkout -- .
fi
}
trap cleanup EXIT

TARGET_FILES=(
"force-app/main/default/classes/Async.cls"
"force-app/main/default/classes/queue/QueueableJob.cls"
"force-app/main/default/classes/queue/QueueableBuilder.cls"
"force-app/main/default/classes/batch/BatchableBuilder.cls"
"force-app/main/default/classes/schedule/SchedulableBuilder.cls"
"force-app/main/default/classes/schedule/CronBuilder.cls"
"force-app/main/default/classes/mocks/AsyncMock.cls"
)

echo "Adding global modifiers to API surface classes..."
for file in "${TARGET_FILES[@]}"; do
sed -i '' 's/public /global /g' "$file"
done

echo "Reverting internal-type references back to public..."
sed -i '' 's/global QueueableManager\.EnqueueType/public QueueableManager.EnqueueType/g' \
"force-app/main/default/classes/Async.cls"
sed -i '' 's/global QueueableChainState setEnqueueType/public QueueableChainState setEnqueueType/g' \
"force-app/main/default/classes/Async.cls"
sed -i '' 's/global void enqueue(QueueableChain chain)/public void enqueue(QueueableChain chain)/g' \
"force-app/main/default/classes/queue/QueueableJob.cls"

echo "Creating unlocked package version..."
sf package version create \
--package "Async Lib" \
--definition-file ./config/project-scratch-def.json \
--installation-key-bypass \
--code-coverage \
--wait 50 \
--json \
"$@"

PACKAGE_CREATED=true
echo "Package version created successfully."
16 changes: 12 additions & 4 deletions sfdx-project.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
{
"packageDirectories": [
{
"versionName": "Async Lib 2.4.0",
"versionNumber": "2.4.0.NEXT",
"path": "force-app",
"default": true
"default": true,
"package": "Async Lib",
"versionDescription": ""
}
],
"name": "BeyondTheCloud",
"namespace": "",
"name": "async-lib",
"namespace": "btcdev",
"sfdcLoginUrl": "https://login.salesforce.com",
"sourceApiVersion": "65.0"
"sourceApiVersion": "65.0",
"packageAliases": {
"Async Lib": "0HoP600000002JZKAY",
"Async Lib@2.4.0-4": "04tP6000002cgEnIAI"
}
}
4 changes: 4 additions & 0 deletions website/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export default defineConfig({
link: '/explanations/initial-scheduled-queuable-batch-job'
},
{ text: 'Job Cloning', link: '/explanations/job-cloning' },
{
text: 'Deep Clone in Packages',
link: '/explanations/deep-clone-in-packages'
},
{ text: 'Testing Async Jobs', link: '/explanations/testing-async-jobs' }
]
}
Expand Down
6 changes: 5 additions & 1 deletion website/api/queueable.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,11 @@ Async.queueable(new MyQueueableJob())

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.md).
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**

Expand Down
113 changes: 113 additions & 0 deletions website/explanations/deep-clone-in-packages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
---
outline: deep
---

# Deep Clone in Packages

## TL;DR

When using Async Lib as a **managed or unlocked package** (with the `btcdev` namespace), `.deepClone()` requires you to override `cloneForDeepCopy()` in your `QueueableJob` subclass. Without this override, the framework cannot serialize and deserialize your job across namespace boundaries.

```apex
public class MyJob extends btcdev.QueueableJob {

public override btcdev.QueueableJob cloneForDeepCopy() {
return (btcdev.QueueableJob) JSON.deserialize(JSON.serialize(this), MyJob.class);
}

public override void work() {
// your logic
}
}
```

If you deploy Async Lib **without a namespace** (e.g., via the Deploy button or `sf project deploy`), no override is needed.

## 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
```

## The Solution

The `cloneForDeepCopy()` method is `virtual`, allowing you to override it in your subclass. Your override runs in your namespace context, where both serialization and type resolution work correctly.

```apex
public class AccountProcessorJob extends btcdev.QueueableJob {
public List<Account> accounts;
public Map<String, Object> config;

public override btcdev.QueueableJob cloneForDeepCopy() {
return (btcdev.QueueableJob) JSON.deserialize(
JSON.serialize(this), AccountProcessorJob.class
);
}

public override void work() {
// process accounts
}
}
```

Then use `.deepClone()` as normal:

```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 Message

If you forget the override, the framework throws a descriptive error:

```
deepClone() failed for the job "MyJob".
When using a namespaced package, override cloneForDeepCopy() in your QueueableJob subclass:
public override QueueableJob cloneForDeepCopy() {
return (QueueableJob) JSON.deserialize(JSON.serialize(this), YourClassName.class);
}
```

## 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.
4 changes: 4 additions & 0 deletions website/explanations/job-cloning.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ Async.queueable(myJob)
- **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
Expand Down
16 changes: 16 additions & 0 deletions website/introduction/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ outline: deep

# Installation <Badge type="tip" text="v2.0.0" />

## Install as Unlocked Package

Install the latest version of Async Lib as an unlocked package:

<a href="https://login.salesforce.com/packaging/installPackage.apexp?p0=04tP6000002cgEnIAI">
<img alt="Install Unlocked Package" src="https://img.shields.io/badge/Install-Unlocked%20Package-blue?style=for-the-badge&logo=salesforce">
</a>

```
https://login.salesforce.com/packaging/installPackage.apexp?p0=04tP6000002cgEnIAI
```

::: tip
When installed as a package, all classes use the `btcdev` namespace prefix (e.g., `btcdev.QueueableJob`, `btcdev.Async`). If you use [`.deepClone()`](/api/queueable#deepclone), see [Deep Clone in Packages](/explanations/deep-clone-in-packages) for a required override.
:::

## Deploy via Button

Deploy to your Salesforce org using the deploy button:
Expand Down