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
182 changes: 182 additions & 0 deletions force-app/main/default/classes/AsyncTest.cls
Original file line number Diff line number Diff line change
Expand Up @@ -2939,6 +2939,188 @@ private class AsyncTest implements Database.Batchable<SObject> {
}
}

@IsTest
private static void shouldCacheClassNamePerInstanceWithoutCrossContamination() {
SuccessfulQueueableTest jobA = new SuccessfulQueueableTest();
FailureQueueableTest jobB = new FailureQueueableTest();

String jobAClassName = jobA.className;
String jobBClassName = jobB.className;

Assert.areEqual(
getClassNameWithNamespaceDotPrefix('AsyncTest.SuccessfulQueueableTest'),
jobAClassName
);
Assert.areEqual(
getClassNameWithNamespaceDotPrefix('AsyncTest.FailureQueueableTest'),
jobBClassName
);
Assert.areEqual(jobAClassName, jobA.className, 'Repeated access returns the same name.');
Assert.areEqual(
jobAClassName,
jobA.cloneJob().className,
'A shallow clone reports its own class name.'
);

SuccessfulQueueableTest roundTripped = (SuccessfulQueueableTest) JSON.deserialize(
JSON.serialize(jobA),
SuccessfulQueueableTest.class
);
Assert.areEqual(
jobAClassName,
roundTripped.className,
'A JSON round trip recomputes the name from the runtime type.'
);
}

@IsTest
private static void shouldDeleteOnlyOldFailedResultsWhenOnlyFailedRetentionIsSet() {
List<AsyncResult__c> oldFailedResults = new List<AsyncResult__c>();
for (Integer i = 0; i < 200; i++) {
oldFailedResults.add(new AsyncResult__c(Status__c = QueueableManager.STATUS_FAILED));
}
insert oldFailedResults;
for (AsyncResult__c result : oldFailedResults) {
Test.setCreatedDate(result.Id, System.now().addDays(-31));
}
AsyncResult__c recentFailed = insertAsyncResultWithAge(QueueableManager.STATUS_FAILED, 5);
AsyncResult__c veryOldCompleted = insertAsyncResultWithAge(
QueueableManager.STATUS_COMPLETED,
400
);

Test.startTest();
Async.batchable(new AsyncResultCleanupBatch().failedOlderThanDays(30)).execute();
Test.stopTest();

Set<Id> remainingIds = new Map<Id, AsyncResult__c>([SELECT Id FROM AsyncResult__c])
.keySet();
Assert.areEqual(2, remainingIds.size(), 'Only the two out-of-track results survive.');
Assert.isTrue(remainingIds.contains(recentFailed.Id), 'Recent failed results survive.');
Assert.isTrue(
remainingIds.contains(veryOldCompleted.Id),
'Non-failed results are untouched when their track has no retention.'
);
}

@IsTest
private static void shouldDeleteOnlyOldNonFailedResultsWhenOnlyOthersRetentionIsSet() {
AsyncResult__c oldCompleted = insertAsyncResultWithAge(
QueueableManager.STATUS_COMPLETED,
31
);
AsyncResult__c oldSkipped = insertAsyncResultWithAge(
QueueableManager.STATUS_SKIPPED_DEPENDENCY,
31
);
AsyncResult__c veryOldFailed = insertAsyncResultWithAge(
QueueableManager.STATUS_FAILED,
400
);
AsyncResult__c recentCompleted = insertAsyncResultWithAge(
QueueableManager.STATUS_COMPLETED,
5
);

Test.startTest();
Async.batchable(new AsyncResultCleanupBatch().othersOlderThanDays(30)).execute();
Test.stopTest();

Set<Id> remainingIds = new Map<Id, AsyncResult__c>([SELECT Id FROM AsyncResult__c])
.keySet();
Assert.areEqual(2, remainingIds.size(), 'Old completed and skipped results are deleted.');
Assert.isTrue(
remainingIds.contains(veryOldFailed.Id),
'Failed results are untouched when their track has no retention.'
);
Assert.isTrue(
remainingIds.contains(recentCompleted.Id),
'Recent non-failed results survive.'
);
}

@IsTest
private static void shouldApplySeparateRetentionCutoffsPerTrack() {
AsyncResult__c failedPastCutoff = insertAsyncResultWithAge(
QueueableManager.STATUS_FAILED,
100
);
AsyncResult__c failedWithinCutoff = insertAsyncResultWithAge(
QueueableManager.STATUS_FAILED,
60
);
AsyncResult__c completedPastCutoff = insertAsyncResultWithAge(
QueueableManager.STATUS_COMPLETED,
60
);
AsyncResult__c completedWithinCutoff = insertAsyncResultWithAge(
QueueableManager.STATUS_COMPLETED,
10
);

Test.startTest();
Async.batchable(
new AsyncResultCleanupBatch().failedOlderThanDays(90).othersOlderThanDays(30)
)
.execute();
Test.stopTest();

Set<Id> remainingIds = new Map<Id, AsyncResult__c>([SELECT Id FROM AsyncResult__c])
.keySet();
Assert.areEqual(2, remainingIds.size(), 'Each track applies its own cutoff.');
Assert.isTrue(
remainingIds.contains(failedWithinCutoff.Id),
'A failed result younger than the failed retention survives.'
);
Assert.isTrue(
remainingIds.contains(completedWithinCutoff.Id),
'A non-failed result younger than the others retention survives.'
);
}

@IsTest
private static void shouldThrowWhenNoRetentionTrackIsConfigured() {
try {
new AsyncResultCleanupBatch().start(null);
Assert.fail('Running the cleanup with no retention configured must throw.');
} catch (IllegalArgumentException ex) {
Assert.areEqual(
AsyncResultCleanupBatch.ERROR_MESSAGE_NO_RETENTION_CONFIGURED,
ex.getMessage()
);
}
}

@IsTest
private static void shouldRejectNonPositiveRetentionDays() {
try {
new AsyncResultCleanupBatch().failedOlderThanDays(0);
Assert.fail('Zero retention days must be rejected.');
} catch (IllegalArgumentException ex) {
Assert.areEqual(
AsyncResultCleanupBatch.ERROR_MESSAGE_INVALID_RETENTION_DAYS,
ex.getMessage()
);
}

try {
new AsyncResultCleanupBatch().othersOlderThanDays(null);
Assert.fail('Null retention days must be rejected.');
} catch (IllegalArgumentException ex) {
Assert.areEqual(
AsyncResultCleanupBatch.ERROR_MESSAGE_INVALID_RETENTION_DAYS,
ex.getMessage()
);
}
}

private static AsyncResult__c insertAsyncResultWithAge(String status, Integer ageDays) {
AsyncResult__c result = new AsyncResult__c(Status__c = status);
insert result;
Test.setCreatedDate(result.Id, System.now().addDays(-ageDays));
return result;
}

private class DeepCloneFailJob extends QueueableJob {
public override void work() {
}
Expand Down
77 changes: 77 additions & 0 deletions force-app/main/default/classes/cleanup/AsyncResultCleanupBatch.cls
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
public inherited sharing class AsyncResultCleanupBatch implements Database.Batchable<SObject> {
public static final String ERROR_MESSAGE_NO_RETENTION_CONFIGURED = 'Configure at least one retention track: failedOlderThanDays(...) and/or othersOlderThanDays(...).';
public static final String ERROR_MESSAGE_INVALID_RETENTION_DAYS = 'Retention days must be greater than zero.';

public Integer failedRetentionDays;
public Integer othersRetentionDays;

public AsyncResultCleanupBatch failedOlderThanDays(Integer days) {
validateDays(days);
this.failedRetentionDays = days;
return this;
}

public AsyncResultCleanupBatch othersOlderThanDays(Integer days) {
validateDays(days);
this.othersRetentionDays = days;
return this;
}

public Database.QueryLocator start(Database.BatchableContext ctx) {
if (failedRetentionDays == null && othersRetentionDays == null) {
throw new IllegalArgumentException(ERROR_MESSAGE_NO_RETENTION_CONFIGURED);
}
Datetime failedCutoff = cutoff(failedRetentionDays);
Datetime othersCutoff = cutoff(othersRetentionDays);
if (othersCutoff == null) {
return Database.getQueryLocator(
[
SELECT Id
FROM AsyncResult__c
WHERE
Status__c = :QueueableManager.STATUS_FAILED
AND CreatedDate < :failedCutoff
]
);
}
if (failedCutoff == null) {
return Database.getQueryLocator(
[
SELECT Id
FROM AsyncResult__c
WHERE
Status__c != :QueueableManager.STATUS_FAILED
AND CreatedDate < :othersCutoff
]
);
}
return Database.getQueryLocator(
[
SELECT Id
FROM AsyncResult__c
WHERE
(Status__c = :QueueableManager.STATUS_FAILED
AND CreatedDate < :failedCutoff)
OR (Status__c != :QueueableManager.STATUS_FAILED
AND CreatedDate < :othersCutoff)
]
);
}

public void execute(Database.BatchableContext ctx, List<AsyncResult__c> scope) {
delete as system scope;
}

public void finish(Database.BatchableContext ctx) {
}

private void validateDays(Integer days) {
if (days == null || days <= 0) {
throw new IllegalArgumentException(ERROR_MESSAGE_INVALID_RETENTION_DAYS);
}
}

private Datetime cutoff(Integer retentionDays) {
return retentionDays == null ? null : System.now().addDays(-retentionDays);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>66.0</apiVersion>
<status>Active</status>
</ApexClass>
7 changes: 5 additions & 2 deletions force-app/main/default/classes/queue/QueueableJob.cls
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
public abstract class QueueableJob implements Queueable, Comparable {
public Id salesforceJobId;
public String customJobId;
public String className {
public transient String className {
get {
return getFullClassName(this);
if (className == null) {
className = getFullClassName(this);
}
return className;
}
private set;
}
Expand Down
1 change: 1 addition & 0 deletions scripts/create-unlocked-package-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ TARGET_FILES=(
"force-app/main/default/classes/queue/QueueableBuilder.cls"
"force-app/main/default/classes/queue/Backoff.cls"
"force-app/main/default/classes/batch/BatchableBuilder.cls"
"force-app/main/default/classes/cleanup/AsyncResultCleanupBatch.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"
Expand Down
10 changes: 9 additions & 1 deletion website/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,15 @@ export default defineConfig({
text: 'Deep Clone in Packages',
link: '/explanations/deep-clone-in-packages'
},
{ text: 'Testing Async Jobs', link: '/explanations/testing-async-jobs' }
{ text: 'Testing Async Jobs', link: '/explanations/testing-async-jobs' },
{
text: 'AsyncResult Cleanup',
link: '/explanations/asyncresult-cleanup'
},
{
text: 'Expected Exceptions in Debug Logs',
link: '/explanations/expected-exceptions-in-debug-logs'
}
]
}
],
Expand Down
8 changes: 8 additions & 0 deletions website/api/batchable.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ Async.Result result = Async.batchable(new AccountCleanupBatch())
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:
Expand Down
Loading
Loading