diff --git a/force-app/main/default/classes/AsyncTest.cls b/force-app/main/default/classes/AsyncTest.cls index 12f909f..447e80d 100644 --- a/force-app/main/default/classes/AsyncTest.cls +++ b/force-app/main/default/classes/AsyncTest.cls @@ -2939,6 +2939,188 @@ private class AsyncTest implements Database.Batchable { } } + @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 oldFailedResults = new List(); + 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 remainingIds = new Map([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 remainingIds = new Map([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 remainingIds = new Map([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() { } diff --git a/force-app/main/default/classes/cleanup/AsyncResultCleanupBatch.cls b/force-app/main/default/classes/cleanup/AsyncResultCleanupBatch.cls new file mode 100644 index 0000000..3a868b8 --- /dev/null +++ b/force-app/main/default/classes/cleanup/AsyncResultCleanupBatch.cls @@ -0,0 +1,77 @@ +public inherited sharing class AsyncResultCleanupBatch implements Database.Batchable { + 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 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); + } +} diff --git a/force-app/main/default/classes/cleanup/AsyncResultCleanupBatch.cls-meta.xml b/force-app/main/default/classes/cleanup/AsyncResultCleanupBatch.cls-meta.xml new file mode 100644 index 0000000..cad713d --- /dev/null +++ b/force-app/main/default/classes/cleanup/AsyncResultCleanupBatch.cls-meta.xml @@ -0,0 +1,5 @@ + + + 66.0 + Active + diff --git a/force-app/main/default/classes/queue/QueueableJob.cls b/force-app/main/default/classes/queue/QueueableJob.cls index 6949318..3d23f30 100644 --- a/force-app/main/default/classes/queue/QueueableJob.cls +++ b/force-app/main/default/classes/queue/QueueableJob.cls @@ -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; } diff --git a/scripts/create-unlocked-package-version.sh b/scripts/create-unlocked-package-version.sh index 266ab87..4edc6f6 100755 --- a/scripts/create-unlocked-package-version.sh +++ b/scripts/create-unlocked-package-version.sh @@ -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" diff --git a/website/.vitepress/config.mts b/website/.vitepress/config.mts index f99eb4b..ea6f301 100644 --- a/website/.vitepress/config.mts +++ b/website/.vitepress/config.mts @@ -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' + } ] } ], diff --git a/website/api/batchable.md b/website/api/batchable.md index 9fa43f4..bdc63ad 100644 --- a/website/api/batchable.md +++ b/website/api/batchable.md @@ -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: diff --git a/website/api/queueable.md b/website/api/queueable.md index 8e7a1d5..bc44672 100644 --- a/website/api/queueable.md +++ b/website/api/queueable.md @@ -62,6 +62,15 @@ public class ProcessorFinalizer extends QueueableJob.Finalizer { } ``` +::: 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). + +::: + ## Methods The following are methods for using Async with Queueable jobs: @@ -312,9 +321,12 @@ 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. For jobs carrying mutable member state, combine with -[`deepClone()`](#deepclone). ::: +::: tip Idempotency + +A retried job re-runs `work()`, so make retried jobs idempotent. For jobs +carrying mutable member state, combine with [`deepClone()`](#deepclone). + +::: **Signature** @@ -449,9 +461,12 @@ 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). +::: 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** @@ -691,14 +706,17 @@ 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. ::: +::: 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 { diff --git a/website/explanations/asyncresult-cleanup.md b/website/explanations/asyncresult-cleanup.md new file mode 100644 index 0000000..57e9665 --- /dev/null +++ b/website/explanations/asyncresult-cleanup.md @@ -0,0 +1,76 @@ +--- +outline: deep +--- + +# 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_EXPLICIT`, `SKIPPED_DISABLED` | `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(); +``` diff --git a/website/explanations/expected-exceptions-in-debug-logs.md b/website/explanations/expected-exceptions-in-debug-logs.md new file mode 100644 index 0000000..3786bd7 --- /dev/null +++ b/website/explanations/expected-exceptions-in-debug-logs.md @@ -0,0 +1,68 @@ +--- +outline: deep +--- + +# 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. diff --git a/website/getting-started.md b/website/getting-started.md index b598931..f2cd076 100644 --- a/website/getting-started.md +++ b/website/getting-started.md @@ -253,6 +253,10 @@ why. Query by **ChainId\_\_c** to see a whole chain run as a timeline. - **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: