From 3582283e206c080ff9c2ad21e298131ed8e39481 Mon Sep 17 00:00:00 2001 From: Mateusz7410 Date: Sun, 31 May 2026 20:55:22 +0000 Subject: [PATCH 1/2] Add retry, dependency gating, and richer result tracking to Queueable Retry & backoff: - retry(n), capped at 10 (throws above the cap instead of silently clamping) - Backoff strategies (fixed / exponential / exponential-with-jitter) as a top-level Backoff class; retryOn(...) to scope retried exceptions (every exception retried by default), with QueueableJobSetting__mdt defaults Dependency gating & chain control: - dependsOn(Async.after(result|customJobId) / afterPrevious() + succeeded()/failed()/finished()) skips a job and its dependents when a dependency's outcome doesn't match; identity is the auto-generated customJobId - imperative Async.stopChain() / Async.skipJob(customJobId), usable from a finalizer - reconcile job outcome from FinalizerContext so uncatchable failures are detected - fix rollbackOnJobExecuteFail to roll back and continue (was a no-op on its own) Observability: - one AsyncResult__c row per job (ran or skipped) with Status, ChainId, ClassName, exception detail, skip reason, retry count, and a DependsOnResult self-lookup - AsyncResultAccess permission set granting read FLS (these fields had none) Docs updated. RunLocalTests passes at 93% coverage on the non-namespaced and btcdev-namespaced scratch orgs. --- force-app/main/default/classes/Async.cls | 57 ++ force-app/main/default/classes/AsyncTest.cls | 851 ++++++++++++++++++ .../main/default/classes/queue/Backoff.cls | 65 ++ .../classes/queue/Backoff.cls-meta.xml | 5 + .../classes/queue/QueueableBuilder.cls | 56 +- .../default/classes/queue/QueueableChain.cls | 399 +++++++- .../default/classes/queue/QueueableJob.cls | 63 +- .../classes/queue/QueueableManager.cls | 21 + .../fields/ActualOutcome__c.field-meta.xml | 30 + .../fields/ChainId__c.field-meta.xml | 12 + .../fields/ClassName__c.field-meta.xml | 11 + .../fields/DependsOnResult__c.field-meta.xml | 11 + .../fields/ExceptionMessage__c.field-meta.xml | 11 + .../fields/ExceptionType__c.field-meta.xml | 11 + .../fields/RequiredOutcome__c.field-meta.xml | 30 + .../fields/RetryAttempts__c.field-meta.xml | 11 + .../fields/RetryHistory__c.field-meta.xml | 11 + .../fields/SkipReason__c.field-meta.xml | 11 + .../fields/Status__c.field-meta.xml | 45 + .../BackoffBaseMinutes__c.field-meta.xml | 12 + .../fields/BackoffStrategy__c.field-meta.xml | 11 + .../fields/MaxRetries__c.field-meta.xml | 12 + .../RetryableExceptions__c.field-meta.xml | 11 + .../AsyncResultAccess.permissionset-meta.xml | 85 ++ website/api/queueable.md | 233 ++++- website/getting-started.md | 22 +- 26 files changed, 2059 insertions(+), 38 deletions(-) create mode 100644 force-app/main/default/classes/queue/Backoff.cls create mode 100644 force-app/main/default/classes/queue/Backoff.cls-meta.xml create mode 100644 force-app/main/default/objects/AsyncResult__c/fields/ActualOutcome__c.field-meta.xml create mode 100644 force-app/main/default/objects/AsyncResult__c/fields/ChainId__c.field-meta.xml create mode 100644 force-app/main/default/objects/AsyncResult__c/fields/ClassName__c.field-meta.xml create mode 100644 force-app/main/default/objects/AsyncResult__c/fields/DependsOnResult__c.field-meta.xml create mode 100644 force-app/main/default/objects/AsyncResult__c/fields/ExceptionMessage__c.field-meta.xml create mode 100644 force-app/main/default/objects/AsyncResult__c/fields/ExceptionType__c.field-meta.xml create mode 100644 force-app/main/default/objects/AsyncResult__c/fields/RequiredOutcome__c.field-meta.xml create mode 100644 force-app/main/default/objects/AsyncResult__c/fields/RetryAttempts__c.field-meta.xml create mode 100644 force-app/main/default/objects/AsyncResult__c/fields/RetryHistory__c.field-meta.xml create mode 100644 force-app/main/default/objects/AsyncResult__c/fields/SkipReason__c.field-meta.xml create mode 100644 force-app/main/default/objects/AsyncResult__c/fields/Status__c.field-meta.xml create mode 100644 force-app/main/default/objects/QueueableJobSetting__mdt/fields/BackoffBaseMinutes__c.field-meta.xml create mode 100644 force-app/main/default/objects/QueueableJobSetting__mdt/fields/BackoffStrategy__c.field-meta.xml create mode 100644 force-app/main/default/objects/QueueableJobSetting__mdt/fields/MaxRetries__c.field-meta.xml create mode 100644 force-app/main/default/objects/QueueableJobSetting__mdt/fields/RetryableExceptions__c.field-meta.xml create mode 100644 force-app/main/default/permissionsets/AsyncResultAccess.permissionset-meta.xml diff --git a/force-app/main/default/classes/Async.cls b/force-app/main/default/classes/Async.cls index 880758b..f636834 100644 --- a/force-app/main/default/classes/Async.cls +++ b/force-app/main/default/classes/Async.cls @@ -15,6 +15,18 @@ public inherited sharing class Async { return new SchedulableBuilder(scheduleJob); } + public static Dependency after(String customJobId) { + return new Dependency().onCustomJobId(customJobId); + } + + public static Dependency after(Result dependency) { + return new Dependency().onCustomJobId(dependency?.customJobId); + } + + public static Dependency afterPrevious() { + return new Dependency().onPrevious(); + } + public static QueueableJobContext getQueueableJobContext() { return QueueableManager.get().getQueueableJobContext(); } @@ -27,6 +39,14 @@ public inherited sharing class Async { return new QueueableChainState().setCurrentQueueableChainState(); } + public static void stopChain() { + QueueableManager.get().stopChain(); + } + + public static void skipJob(String customJobId) { + QueueableManager.get().skipJob(customJobId); + } + public class QueueableJobContext { public QueueableJob currentJob; public QueueableContext queueableCtx { @@ -126,6 +146,43 @@ public inherited sharing class Async { SCHEDULABLE } + public enum Outcome { + SUCCESS, + FAILURE, + COMPLETED + } + + public class Dependency { + public Boolean previous = false; + public String resolvedTargetCustomJobId; + public Outcome requiredOutcome; + + public Dependency onCustomJobId(String customJobId) { + this.resolvedTargetCustomJobId = customJobId; + return this; + } + + public Dependency onPrevious() { + this.previous = true; + return this; + } + + public Dependency succeeded() { + this.requiredOutcome = Outcome.SUCCESS; + return this; + } + + public Dependency failed() { + this.requiredOutcome = Outcome.FAILURE; + return this; + } + + public Dependency finished() { + this.requiredOutcome = Outcome.COMPLETED; + return this; + } + } + public class IllegalArgumentException extends Exception { } } diff --git a/force-app/main/default/classes/AsyncTest.cls b/force-app/main/default/classes/AsyncTest.cls index e24c09a..8f7ef24 100644 --- a/force-app/main/default/classes/AsyncTest.cls +++ b/force-app/main/default/classes/AsyncTest.cls @@ -1777,6 +1777,834 @@ private class AsyncTest implements Database.Batchable { Assert.areEqual(ParentJobResult.SUCCESS, ctx2.getResult()); } + @IsTest + private static void shouldComputeFixedBackoff() { + Backoff policy = Backoff.fixed(3); + Assert.areEqual(3, policy.delayMinutes(1)); + Assert.areEqual(3, policy.delayMinutes(5)); + Assert.areEqual( + 10, + Backoff.fixed(20).delayMinutes(1), + 'Delay must be capped at 10 minutes.' + ); + } + + @IsTest + private static void shouldComputeExponentialBackoff() { + Backoff policy = Backoff.exponential(1); + Assert.areEqual(1, policy.delayMinutes(1)); + Assert.areEqual(2, policy.delayMinutes(2)); + Assert.areEqual(4, policy.delayMinutes(3)); + Assert.areEqual(8, policy.delayMinutes(4)); + Assert.areEqual(10, policy.delayMinutes(5), 'Should be capped at 10 minutes.'); + Assert.areEqual(10, policy.delayMinutes(9), 'Should stay capped at 10 minutes.'); + } + + @IsTest + private static void shouldComputeExponentialJitterWithinBounds() { + Backoff policy = Backoff.exponentialWithJitter(2); + for (Integer attempt = 1; attempt <= 6; attempt++) { + Integer delay = policy.delayMinutes(attempt); + Assert.isTrue(delay >= 0, 'Delay must be non-negative.'); + Assert.isTrue(delay <= 10, 'Delay must be capped at 10 minutes.'); + } + } + + @IsTest + private static void shouldBuildBackoffFromName() { + Assert.isNotNull(Backoff.fromName('EXPONENTIAL', 2)); + Assert.isNotNull(Backoff.fromName('fixed', 2)); + Assert.isNull(Backoff.fromName('NOT_A_STRATEGY', 2)); + Assert.isNull(Backoff.fromName(null, 2)); + } + + @IsTest + private static void shouldThrowOnNegativeBackoffBase() { + try { + Backoff.fixed(-1); + Assert.fail('Should reject a negative base.'); + } catch (Async.IllegalArgumentException ex) { + Assert.isTrue(ex.getMessage().contains('non-negative')); + } + } + + @IsTest + private static void shouldRetryOnAnyExceptionByDefault() { + QueueableJobTest1 job = new QueueableJobTest1(); + job.hasFailed = true; + job.failedExceptionType = 'System.DmlException'; + job.maxRetries = 2; + job.retryAttempt = 0; + + Assert.isTrue(job.canRetry()); + } + + @IsTest + private static void shouldRetryLimitExceptionByDefault() { + QueueableJobTest1 job = new QueueableJobTest1(); + job.hasFailed = true; + job.failedExceptionType = 'System.LimitException'; + job.maxRetries = 3; + + Assert.isTrue(job.canRetry(), 'With no retryOn filter, every exception is retried.'); + } + + @IsTest + private static void shouldRetryLimitExceptionWhenWhitelisted() { + QueueableJobTest1 job = new QueueableJobTest1(); + job.hasFailed = true; + job.failedExceptionType = 'System.LimitException'; + job.maxRetries = 3; + job.retryOnExceptionTypes = new Set{ 'System.LimitException' }; + + Assert.isTrue(job.canRetry()); + } + + @IsTest + private static void shouldNotRetryExceptionOutsideWhitelist() { + QueueableJobTest1 job = new QueueableJobTest1(); + job.hasFailed = true; + job.failedExceptionType = 'System.CalloutException'; + job.maxRetries = 3; + job.retryOnExceptionTypes = new Set{ 'System.DmlException' }; + + Assert.isFalse(job.canRetry()); + } + + @IsTest + private static void shouldRetryExceptionMatchingShortName() { + QueueableJobTest1 job = new QueueableJobTest1(); + job.hasFailed = true; + job.failedExceptionType = 'System.DmlException'; + job.maxRetries = 3; + job.retryOnExceptionTypes = new Set{ 'DmlException' }; + + Assert.isTrue(job.canRetry()); + } + + @IsTest + private static void shouldNotRetryWhenAttemptsExhausted() { + QueueableJobTest1 job = new QueueableJobTest1(); + job.hasFailed = true; + job.failedExceptionType = 'System.DmlException'; + job.maxRetries = 2; + job.retryAttempt = 2; + + Assert.isFalse(job.canRetry()); + } + + @IsTest + private static void shouldNotRetryWhenNotFailed() { + QueueableJobTest1 job = new QueueableJobTest1(); + job.hasFailed = false; + job.maxRetries = 2; + + Assert.isFalse(job.canRetry()); + } + + @IsTest + private static void shouldRejectNegativeRetryCount() { + try { + Async.queueable(new QueueableJobTest1()).retry(-1); + Assert.fail('Should reject a negative retry count.'); + } catch (Exception ex) { + Assert.areEqual(QueueableManager.ERROR_MESSAGE_INVALID_MAX_RETRIES, ex.getMessage()); + } + } + + @IsTest + private static void shouldRejectRetryCountAboveCap() { + try { + Async.queueable(new QueueableJobTest1()).retry(QueueableManager.MAX_RETRY_CAP + 1); + Assert.fail('Should reject a retry count above the framework cap.'); + } catch (Exception ex) { + Assert.areEqual( + QueueableManager.ERROR_MESSAGE_MAX_RETRIES_EXCEEDS_CAP, + ex.getMessage() + ); + } + } + + @IsTest + private static void shouldRegisterRetryOnExceptionTypes() { + Test.startTest(); + Async.Result result = Async.queueable(new QueueableJobTest1()) + .retry(2) + .retryOn(DmlException.class) + .retryOn(CalloutException.class) + .enqueue(); + Test.stopTest(); + + Assert.areEqual(2, result.job.retryOnExceptionTypes.size()); + } + + @IsTest + private static void shouldApplyRetryDefaultsFromCmdt() { + QueueableJobTest1 job = new QueueableJobTest1(); + + QueueableChain chain = new QueueableChain(); + chain.queueableJobSettingByJobName = new Map{ + QueueableManager.QUEUEABLE_JOB_SETTING_ALL => new QueueableJobSetting__mdt( + DeveloperName = QueueableManager.QUEUEABLE_JOB_SETTING_ALL, + MaxRetries__c = 3, + BackoffStrategy__c = 'EXPONENTIAL', + BackoffBaseMinutes__c = 2, + RetryableExceptions__c = 'System.DmlException, System.CalloutException' + ) + }; + chain.addJob(job); + + Assert.areEqual(3, job.maxRetries); + Assert.isNotNull(job.backoff); + Assert.areEqual(2, job.retryOnExceptionTypes.size()); + Assert.isTrue(job.retryOnExceptionTypes.contains('System.DmlException')); + } + + @IsTest + private static void shouldNotOverrideExplicitRetryWithCmdt() { + QueueableJobTest1 job = new QueueableJobTest1(); + job.maxRetries = 5; + + QueueableChain chain = new QueueableChain(); + chain.queueableJobSettingByJobName = new Map{ + QueueableManager.QUEUEABLE_JOB_SETTING_ALL => new QueueableJobSetting__mdt( + DeveloperName = QueueableManager.QUEUEABLE_JOB_SETTING_ALL, + MaxRetries__c = 3 + ) + }; + chain.addJob(job); + + Assert.areEqual(5, job.maxRetries, 'Explicit fluent config must win over CMDT.'); + } + + @IsTest + private static void shouldRejectCmdtMaxRetriesAboveCap() { + QueueableJobTest1 job = new QueueableJobTest1(); + + QueueableChain chain = new QueueableChain(); + chain.queueableJobSettingByJobName = new Map{ + QueueableManager.QUEUEABLE_JOB_SETTING_ALL => new QueueableJobSetting__mdt( + DeveloperName = QueueableManager.QUEUEABLE_JOB_SETTING_ALL, + MaxRetries__c = QueueableManager.MAX_RETRY_CAP + 1 + ) + }; + + try { + chain.addJob(job); + Assert.fail('Should reject a CMDT retry count above the framework cap.'); + } catch (Exception ex) { + Assert.areEqual( + QueueableManager.ERROR_MESSAGE_MAX_RETRIES_EXCEEDS_CAP, + ex.getMessage() + ); + } + } + + @IsTest + private static void shouldReEnqueueRetryJobOnFailure() { + FailureQueueableTest job = new FailureQueueableTest(); + + QueueableChain chain = new QueueableChain(); + chain.addJob(job); + job.maxRetries = 2; + job.backoff = Backoff.fixed(5); + job.continueOnJobExecuteFail = true; + QueueableManager.get().setChain(chain); + + Test.startTest(); + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + chain.enqueueNextJobIfAnyFromFinalizer( + new AsyncMock.MockFinalizerContext().setResult(ParentJobResult.SUCCESS) + ); + Test.stopTest(); + + Assert.areEqual(2, chain.jobs.size(), 'A retry clone should have been inserted.'); + Assert.areEqual(1, chain.jobs[0].retryAttempt); + Assert.areEqual(5, chain.jobs[0].delay, 'Backoff delay should be applied to the retry.'); + Assert.isTrue( + String.isNotBlank(chain.jobs[0].retryHistory), + 'Retry history should be recorded.' + ); + } + + @IsTest + private static void shouldRetryFailedQueueableUntilExhausted() { + Assert.areEqual(0, [SELECT COUNT() FROM Account]); + + Test.startTest(); + Async.queueable(new FailureQueueableTest()).continueOnJobExecuteFail().retry(2).enqueue(); + Test.stopTest(); + + Assert.areEqual( + 3, + [SELECT COUNT() FROM Account], + 'Original run plus 2 retries should each execute work() once.' + ); + } + + @IsTest + private static void shouldNotRetryWhenNotConfigured() { + Assert.areEqual(0, [SELECT COUNT() FROM Account]); + + Test.startTest(); + Async.queueable(new FailureQueueableTest()).continueOnJobExecuteFail().enqueue(); + Test.stopTest(); + + Assert.areEqual( + 1, + [SELECT COUNT() FROM Account], + 'No retry config means a single execution.' + ); + } + + private static Async.Dependency dependencyOn(String customJobId, Async.Outcome outcome) { + Async.Dependency dependency = new Async.Dependency(); + dependency.resolvedTargetCustomJobId = customJobId; + dependency.requiredOutcome = outcome; + return dependency; + } + + @IsTest + private static void shouldRunDependentJobWhenRequiredOutcomeMatches() { + SuccessfulQueueableTest jobA = new SuccessfulQueueableTest(); + SuccessfulQueueableTest jobB = new SuccessfulQueueableTest(); + + QueueableChain chain = new QueueableChain(); + chain.addJob(jobA); + jobB.dependencies = new List{ + dependencyOn(jobA.customJobId, Async.Outcome.SUCCESS) + }; + chain.addJob(jobB); + QueueableManager.get().setChain(chain); + + Test.startTest(); + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + chain.enqueueNextJobIfAnyFromFinalizer( + new AsyncMock.MockFinalizerContext().setResult(ParentJobResult.SUCCESS) + ); + Test.stopTest(); + + Assert.isFalse(jobB.isProcessed, 'Dependent job should not be skipped.'); + Assert.isNotNull(jobB.salesforceJobId, 'Dependent job should be enqueued.'); + } + + @IsTest + private static void shouldSkipDependentJobWhenRequiredOutcomeMismatches() { + FailureQueueableTest jobA = new FailureQueueableTest(); + jobA.continueOnJobExecuteFail = true; + SuccessfulQueueableTest jobB = new SuccessfulQueueableTest(); + + QueueableChain chain = new QueueableChain(); + chain.addJob(jobA); + jobB.dependencies = new List{ + dependencyOn(jobA.customJobId, Async.Outcome.SUCCESS) + }; + chain.addJob(jobB); + QueueableManager.get().setChain(chain); + + Test.startTest(); + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + chain.enqueueNextJobIfAnyFromFinalizer( + new AsyncMock.MockFinalizerContext().setResult(ParentJobResult.UNHANDLED_EXCEPTION) + ); + Test.stopTest(); + + Assert.isTrue( + jobB.isProcessed, + 'Dependent job should be skipped when its dependency failed.' + ); + Assert.isNull(jobB.salesforceJobId, 'Skipped job should not be enqueued.'); + } + + @IsTest + private static void shouldRunCompensationJobWhenDependencyFails() { + FailureQueueableTest jobA = new FailureQueueableTest(); + jobA.continueOnJobExecuteFail = true; + SuccessfulQueueableTest compensation = new SuccessfulQueueableTest(); + + QueueableChain chain = new QueueableChain(); + chain.addJob(jobA); + compensation.dependencies = new List{ + dependencyOn(jobA.customJobId, Async.Outcome.FAILURE) + }; + chain.addJob(compensation); + QueueableManager.get().setChain(chain); + + Test.startTest(); + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + chain.enqueueNextJobIfAnyFromFinalizer( + new AsyncMock.MockFinalizerContext().setResult(ParentJobResult.UNHANDLED_EXCEPTION) + ); + Test.stopTest(); + + Assert.isFalse( + compensation.isProcessed, + 'Compensation job should run when dependency failed.' + ); + Assert.isNotNull(compensation.salesforceJobId, 'Compensation job should be enqueued.'); + } + + @IsTest + private static void shouldRunFinishedDependencyRegardlessOfOutcome() { + FailureQueueableTest jobA = new FailureQueueableTest(); + jobA.continueOnJobExecuteFail = true; + SuccessfulQueueableTest jobB = new SuccessfulQueueableTest(); + + QueueableChain chain = new QueueableChain(); + chain.addJob(jobA); + jobB.dependencies = new List{ + dependencyOn(jobA.customJobId, Async.Outcome.COMPLETED) + }; + chain.addJob(jobB); + QueueableManager.get().setChain(chain); + + Test.startTest(); + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + chain.enqueueNextJobIfAnyFromFinalizer( + new AsyncMock.MockFinalizerContext().setResult(ParentJobResult.UNHANDLED_EXCEPTION) + ); + Test.stopTest(); + + Assert.isFalse(jobB.isProcessed, 'finished() should match a failed dependency.'); + Assert.isNotNull(jobB.salesforceJobId); + } + + @IsTest + private static void shouldCascadeSkipToTransitiveDependents() { + FailureQueueableTest jobA = new FailureQueueableTest(); + jobA.continueOnJobExecuteFail = true; + SuccessfulQueueableTest jobB = new SuccessfulQueueableTest(); + SuccessfulQueueableTest jobC = new SuccessfulQueueableTest(); + + QueueableChain chain = new QueueableChain(); + chain.addJob(jobA); + jobB.dependencies = new List{ + dependencyOn(jobA.customJobId, Async.Outcome.SUCCESS) + }; + chain.addJob(jobB); + jobC.dependencies = new List{ + dependencyOn(jobB.customJobId, Async.Outcome.SUCCESS) + }; + chain.addJob(jobC); + QueueableManager.get().setChain(chain); + + Test.startTest(); + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + chain.enqueueNextJobIfAnyFromFinalizer( + new AsyncMock.MockFinalizerContext().setResult(ParentJobResult.UNHANDLED_EXCEPTION) + ); + Test.stopTest(); + + Assert.isTrue(jobB.isProcessed, 'B skipped because A failed.'); + Assert.isTrue(jobC.isProcessed, 'C skipped because B never ran.'); + Assert.isNull(jobC.salesforceJobId); + } + + @IsTest + private static void shouldResolveAfterPreviousToPriorJob() { + Async.Result result = Async.queueable(new SuccessfulQueueableTest()) + .chain(new SuccessfulQueueableTest()) + .dependsOn(Async.afterPrevious().succeeded()) + .chain(); + + QueueableJob dependent = result.queueableChainState.jobs[1]; + Assert.areEqual(1, dependent.dependencies.size()); + Assert.isTrue(dependent.dependencies[0].previous); + Assert.areEqual( + result.queueableChainState.jobs[0].customJobId, + dependent.dependencies[0].resolvedTargetCustomJobId, + 'afterPrevious() should resolve to the preceding job.' + ); + } + + @IsTest + private static void shouldResolveAfterResultToTargetJob() { + Async.Result first = Async.queueable(new SuccessfulQueueableTest()).chain(); + Async.Result result = Async.queueable(new SuccessfulQueueableTest()) + .dependsOn(Async.after(first).succeeded()) + .chain(); + + QueueableJob dependent = result.queueableChainState.jobs[1]; + Assert.areEqual( + first.customJobId, + dependent.dependencies[0].resolvedTargetCustomJobId, + 'after(Result) should resolve to the dependency job customJobId.' + ); + } + + @IsTest + private static void shouldRejectAfterPreviousWithoutPriorJob() { + try { + Async.queueable(new SuccessfulQueueableTest()) + .dependsOn(Async.afterPrevious().succeeded()); + Assert.fail('Should reject afterPrevious() with no preceding job.'); + } catch (Exception ex) { + Assert.areEqual( + QueueableManager.ERROR_MESSAGE_DEPENDS_ON_PREVIOUS_WITHOUT_JOB, + ex.getMessage() + ); + } + } + + @IsTest + private static void shouldRejectDependencyWithoutOutcome() { + try { + Async.queueable(new SuccessfulQueueableTest()).dependsOn(Async.after('a')); + Assert.fail('Should reject a dependency missing a required outcome.'); + } catch (Exception ex) { + Assert.areEqual(QueueableManager.ERROR_MESSAGE_INVALID_DEPENDENCY, ex.getMessage()); + } + } + + @IsTest + private static void shouldRollbackAndContinueWhenOnlyRollbackFlagSet() { + Test.startTest(); + Async.queueable(new FailureQueueableTest()) + .rollbackOnJobExecuteFail() + .chain(new SuccessfulQueueableTest()) + .enqueue(); + Test.stopTest(); + + Assert.areEqual( + 1, + [SELECT COUNT() FROM Account], + 'Failing job DML rolled back; chain continued to the next job, which committed.' + ); + } + + @IsTest + private static void shouldReconcileOutcomeFromFinalizerContext() { + SuccessfulQueueableTest jobA = new SuccessfulQueueableTest(); + SuccessfulQueueableTest compensation = new SuccessfulQueueableTest(); + + QueueableChain chain = new QueueableChain(); + chain.addJob(jobA); + compensation.dependencies = new List{ + dependencyOn(jobA.customJobId, Async.Outcome.FAILURE) + }; + chain.addJob(compensation); + QueueableManager.get().setChain(chain); + + Test.startTest(); + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + chain.enqueueNextJobIfAnyFromFinalizer( + new AsyncMock.MockFinalizerContext().setResult(ParentJobResult.UNHANDLED_EXCEPTION) + ); + Test.stopTest(); + + Assert.isTrue(jobA.hasFailed, 'Failure should be reconciled from the FinalizerContext.'); + Assert.isFalse( + compensation.isProcessed, + 'Compensation runs because the reconciled outcome is FAILURE.' + ); + Assert.isNotNull(compensation.salesforceJobId); + } + + @IsTest + private static void shouldStopChainSkippingRemainingJobs() { + SuccessfulQueueableTest jobA = new SuccessfulQueueableTest(); + SuccessfulQueueableTest jobB = new SuccessfulQueueableTest(); + + QueueableChain chain = new QueueableChain(); + chain.addJob(jobA); + chain.addJob(jobB); + QueueableManager.get().setChain(chain); + + Async.stopChain(); + + Assert.isTrue(jobA.isProcessed); + Assert.isTrue(jobB.isProcessed); + Assert.isFalse(chain.hasNextJob(), 'No jobs remain after stopChain().'); + } + + @IsTest + private static void shouldSkipJobByCustomJobId() { + SuccessfulQueueableTest jobA = new SuccessfulQueueableTest(); + SuccessfulQueueableTest jobB = new SuccessfulQueueableTest(); + + QueueableChain chain = new QueueableChain(); + chain.addJob(jobA); + chain.addJob(jobB); + QueueableManager.get().setChain(chain); + + Async.skipJob(jobB.customJobId); + + Assert.isFalse(jobA.isProcessed, 'Other jobs are untouched.'); + Assert.isTrue(jobB.isProcessed, 'The targeted job is skipped.'); + } + + @IsTest + private static void shouldRejectSkipJobWithUnknownCustomJobId() { + QueueableChain chain = new QueueableChain(); + chain.addJob(new SuccessfulQueueableTest()); + QueueableManager.get().setChain(chain); + + try { + Async.skipJob('nope'); + Assert.fail('Should reject skipping an unknown custom job id.'); + } catch (Exception ex) { + Assert.isTrue(ex.getMessage().contains('nope')); + } + } + + @IsTest + private static void shouldStopChainFromAttachedFinalizer() { + Test.startTest(); + Async.queueable(new ChainStoppingJob()).chain(new SuccessfulQueueableTest()).enqueue(); + Test.stopTest(); + + Assert.areEqual( + 0, + [SELECT COUNT() FROM Account], + 'Downstream job is skipped after the finalizer stopped the chain.' + ); + } + + private static Map resultsEnabledForAll() { + return new Map{ + QueueableManager.QUEUEABLE_JOB_SETTING_ALL => new QueueableJobSetting__mdt( + DeveloperName = QueueableManager.QUEUEABLE_JOB_SETTING_ALL, + CreateResult__c = true + ) + }; + } + + @IsTest + private static void shouldCreateCompletedResultWithIdentityFields() { + SuccessfulQueueableTest job = new SuccessfulQueueableTest(); + + QueueableChain chain = new QueueableChain(); + chain.queueableJobSettingByJobName = resultsEnabledForAll(); + chain.addJob(job); + QueueableManager.get().setChain(chain); + + Test.startTest(); + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + chain.enqueueNextJobIfAnyFromFinalizer( + new AsyncMock.MockFinalizerContext().setResult(ParentJobResult.SUCCESS) + ); + Test.stopTest(); + + AsyncResult__c result = [ + SELECT Status__c, ClassName__c, ChainId__c, RetryAttempts__c, Result__c + FROM AsyncResult__c + WHERE CustomJobId__c = :job.customJobId + ]; + Assert.areEqual(QueueableManager.STATUS_COMPLETED, result.Status__c); + Assert.isTrue(result.ClassName__c.contains('SuccessfulQueueableTest')); + Assert.isNotNull(result.ChainId__c); + Assert.areEqual(0, result.RetryAttempts__c); + Assert.areEqual('SUCCESS', result.Result__c); + } + + @IsTest + private static void shouldCreateSkippedDependencyResultLinkedToBlocker() { + FailureQueueableTest jobA = new FailureQueueableTest(); + jobA.continueOnJobExecuteFail = true; + SuccessfulQueueableTest jobB = new SuccessfulQueueableTest(); + + QueueableChain chain = new QueueableChain(); + chain.queueableJobSettingByJobName = resultsEnabledForAll(); + chain.addJob(jobA); + jobB.dependencies = new List{ + dependencyOn(jobA.customJobId, Async.Outcome.SUCCESS) + }; + chain.addJob(jobB); + QueueableManager.get().setChain(chain); + + Test.startTest(); + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + chain.enqueueNextJobIfAnyFromFinalizer( + new AsyncMock.MockFinalizerContext().setResult(ParentJobResult.UNHANDLED_EXCEPTION) + ); + Test.stopTest(); + + AsyncResult__c aResult = [ + SELECT Id, Status__c, ChainId__c, ExceptionType__c + FROM AsyncResult__c + WHERE CustomJobId__c = :jobA.customJobId + ]; + AsyncResult__c bResult = [ + SELECT + Status__c, + SkipReason__c, + DependsOnResult__c, + RequiredOutcome__c, + ActualOutcome__c, + ChainId__c + FROM AsyncResult__c + WHERE CustomJobId__c = :jobB.customJobId + ]; + + Assert.areEqual(QueueableManager.STATUS_FAILED, aResult.Status__c); + Assert.isNotNull(aResult.ExceptionType__c, 'Failed job records the exception type.'); + Assert.areEqual(QueueableManager.STATUS_SKIPPED_DEPENDENCY, bResult.Status__c); + Assert.areEqual( + aResult.Id, + bResult.DependsOnResult__c, + 'B links to the result that blocked it.' + ); + Assert.areEqual('SUCCESS', bResult.RequiredOutcome__c); + Assert.areEqual('FAILURE', bResult.ActualOutcome__c); + Assert.isNotNull(bResult.SkipReason__c); + Assert.areEqual(aResult.ChainId__c, bResult.ChainId__c, 'Both rows share the chain id.'); + } + + @IsTest + private static void shouldCreateDisabledSkipResult() { + QueueableJobTest1 job = new QueueableJobTest1(); + + QueueableChain chain = new QueueableChain(); + Map settings = resultsEnabledForAll(); + settings.put( + getClassNameWithNamespaceDotPrefix('AsyncTest.QueueableJobTest1'), + new QueueableJobSetting__mdt( + DeveloperName = getClassNameWithNamespaceDotPrefix('AsyncTest.QueueableJobTest1'), + IsDisabled__c = true + ) + ); + chain.queueableJobSettingByJobName = settings; + chain.addJob(job); + + Test.startTest(); + chain.removeJobsThatAreDisabledAndDependentFinalizers(); + Test.stopTest(); + + AsyncResult__c result = [ + SELECT Status__c, SkipReason__c + FROM AsyncResult__c + WHERE CustomJobId__c = :job.customJobId + ]; + Assert.areEqual(QueueableManager.STATUS_SKIPPED_DISABLED, result.Status__c); + Assert.isNotNull(result.SkipReason__c); + } + + private static MarkerJob markerJob(String tag, Boolean shouldFail) { + MarkerJob job = new MarkerJob(); + job.tag = tag; + job.shouldFail = shouldFail; + return job; + } + + private static Integer accountCount(String name) { + return [SELECT COUNT() FROM Account WHERE Name = :name]; + } + + @IsTest + private static void shouldGateChainOnDependencyOutcomesEndToEnd() { + Test.startTest(); + Async.Result a = Async.queueable(markerJob('A', true)).continueOnJobExecuteFail().chain(); + Async.queueable(markerJob('B', false)).dependsOn(Async.after(a).succeeded()).chain(); + Async.queueable(markerJob('C', false)).dependsOn(Async.after(a).failed()).enqueue(); + Test.stopTest(); + + Assert.areEqual(1, accountCount('A'), 'A ran (and committed despite failing).'); + Assert.areEqual(0, accountCount('B'), 'B is skipped: it needs A to succeed, but A failed.'); + Assert.areEqual(1, accountCount('C'), 'C runs: it is the compensation for A failing.'); + } + + @IsTest + private static void shouldRecordChainResultsEndToEnd() { + QueueableChain chain = new QueueableChain(); + chain.queueableJobSettingByJobName = resultsEnabledForAll(); + QueueableManager.get().setChain(chain); + + Test.startTest(); + Async.Result a = Async.queueable(markerJob('A', true)).continueOnJobExecuteFail().chain(); + Async.queueable(markerJob('B', false)).dependsOn(Async.after(a).succeeded()).chain(); + Async.queueable(markerJob('C', false)).dependsOn(Async.after(a).failed()).enqueue(); + Test.stopTest(); + + Map byStatus = new Map(); + Set chainIds = new Set(); + for (AsyncResult__c result : [ + SELECT + Status__c, + ChainId__c, + DependsOnResult__c, + ExceptionType__c, + RequiredOutcome__c, + ActualOutcome__c + FROM AsyncResult__c + ]) { + byStatus.put(result.Status__c, result); + chainIds.add(result.ChainId__c); + } + + Assert.areEqual(3, byStatus.size(), 'One result row per job (ran and skipped).'); + Assert.areEqual(1, chainIds.size(), 'All rows share one ChainId.'); + + AsyncResult__c failed = byStatus.get(QueueableManager.STATUS_FAILED); + AsyncResult__c skipped = byStatus.get(QueueableManager.STATUS_SKIPPED_DEPENDENCY); + Assert.isNotNull(byStatus.get(QueueableManager.STATUS_COMPLETED), 'C completed.'); + Assert.isNotNull(failed.ExceptionType__c, 'Failed row captures the exception type.'); + Assert.areEqual( + failed.Id, + skipped.DependsOnResult__c, + 'Skipped B links to the result that blocked it (A).' + ); + Assert.areEqual('SUCCESS', skipped.RequiredOutcome__c); + Assert.areEqual('FAILURE', skipped.ActualOutcome__c); + } + + @IsTest + private static void shouldRunLinearDependencyChainEndToEnd() { + Test.startTest(); + Async.queueable(markerJob('X', false)) + .chain(markerJob('Y', false)) + .dependsOn(Async.afterPrevious().succeeded()) + .chain(markerJob('Z', false)) + .dependsOn(Async.afterPrevious().succeeded()) + .enqueue(); + Test.stopTest(); + + Assert.areEqual(1, accountCount('X')); + Assert.areEqual(1, accountCount('Y'), 'Y runs after X succeeds.'); + Assert.areEqual(1, accountCount('Z'), 'Z runs after Y succeeds.'); + } + + @IsTest + private static void shouldSkipDownstreamWhenLinearDependencyFailsEndToEnd() { + Test.startTest(); + Async.queueable(markerJob('X', true)) + .continueOnJobExecuteFail() + .chain(markerJob('Y', false)) + .dependsOn(Async.afterPrevious().succeeded()) + .chain(markerJob('Z', false)) + .dependsOn(Async.afterPrevious().succeeded()) + .enqueue(); + Test.stopTest(); + + Assert.areEqual(1, accountCount('X'), 'X ran (committed despite failing).'); + Assert.areEqual(0, accountCount('Y'), 'Y skipped: X failed.'); + Assert.areEqual(0, accountCount('Z'), 'Z skipped transitively: Y never ran.'); + } + + @IsTest + private static void shouldRecordRetryHistoryOnExhaustionEndToEnd() { + QueueableChain chain = new QueueableChain(); + chain.queueableJobSettingByJobName = resultsEnabledForAll(); + QueueableManager.get().setChain(chain); + + Test.startTest(); + Async.queueable(markerJob('R', true)).continueOnJobExecuteFail().retry(2).enqueue(); + Test.stopTest(); + + Assert.areEqual(3, accountCount('R'), 'Original run plus 2 retries each execute work().'); + + AsyncResult__c result = [ + SELECT Status__c, RetryAttempts__c, RetryHistory__c + FROM AsyncResult__c + ]; + Assert.areEqual( + QueueableManager.STATUS_FAILED, + result.Status__c, + 'Only the final, exhausted attempt produces a result row.' + ); + Assert.areEqual(2, result.RetryAttempts__c); + Assert.isTrue(String.isNotBlank(result.RetryHistory__c), 'Retry history is recorded.'); + } + private static String getClassNameWithNamespaceDotPrefix(String className) { return getNamespaceDotPrefix() + className; } @@ -1817,6 +2645,29 @@ private class AsyncTest implements Database.Batchable { } } + private class ChainStoppingJob extends QueueableJob { + public override void work() { + Async.queueable(new StopChainFinalizer()).attachFinalizer(); + } + } + + private class MarkerJob extends QueueableJob { + public String tag; + public Boolean shouldFail = false; + public override void work() { + insert new Account(Name = tag); + if (shouldFail) { + throw new CustomException(AsyncTest.CUSTOM_ERROR_MESSAGE); + } + } + } + + private class StopChainFinalizer extends QueueableJob.Finalizer { + public override void work() { + Async.stopChain(); + } + } + private class QueueableTestFinalizer extends QueueableJob.Finalizer { public override void work() { FinalizerContext finalizerCtx = Async.getQueueableJobContext()?.finalizerCtx; diff --git a/force-app/main/default/classes/queue/Backoff.cls b/force-app/main/default/classes/queue/Backoff.cls new file mode 100644 index 0000000..40fcd94 --- /dev/null +++ b/force-app/main/default/classes/queue/Backoff.cls @@ -0,0 +1,65 @@ +public inherited sharing class Backoff { + // System.enqueueJob caps delay at 10 integer minutes. + public static final Integer MAX_DELAY_MINUTES = 10; + + public enum Strategy { + FIXED, + EXPONENTIAL, + EXPONENTIAL_JITTER + } + + private final Strategy strategyValue; + private final Integer baseMinutes; + + private Backoff(Strategy strategyValue, Integer baseMinutes) { + if (baseMinutes == null || baseMinutes < 0) { + throw new Async.IllegalArgumentException( + 'Backoff base minutes must be a non-negative integer' + ); + } + this.strategyValue = strategyValue; + this.baseMinutes = baseMinutes; + } + + public static Backoff fixed(Integer minutes) { + return new Backoff(Strategy.FIXED, minutes); + } + + public static Backoff exponential(Integer baseMinutes) { + return new Backoff(Strategy.EXPONENTIAL, baseMinutes); + } + + public static Backoff exponentialWithJitter(Integer baseMinutes) { + return new Backoff(Strategy.EXPONENTIAL_JITTER, baseMinutes); + } + + public static Backoff fromName(String strategy, Integer baseMinutes) { + Strategy parsed = parseStrategy(strategy); + return parsed == null ? null : new Backoff(parsed, baseMinutes); + } + + private static Strategy parseStrategy(String value) { + if (String.isBlank(value)) { + return null; + } + for (Strategy candidate : Strategy.values()) { + if (candidate.name().equalsIgnoreCase(value)) { + return candidate; + } + } + return null; + } + + public Integer delayMinutes(Integer attempt) { + Integer delay; + if (strategyValue == Strategy.FIXED) { + delay = baseMinutes; + } else { + delay = baseMinutes * (Integer) Math.pow(2, attempt - 1); + if (strategyValue == Strategy.EXPONENTIAL_JITTER) { + delay += (Integer) Math.floor(Math.random() * (baseMinutes + 1)); + } + } + return Math.min(Math.max(delay, 0), MAX_DELAY_MINUTES); + } +} diff --git a/force-app/main/default/classes/queue/Backoff.cls-meta.xml b/force-app/main/default/classes/queue/Backoff.cls-meta.xml new file mode 100644 index 0000000..82775b9 --- /dev/null +++ b/force-app/main/default/classes/queue/Backoff.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + diff --git a/force-app/main/default/classes/queue/QueueableBuilder.cls b/force-app/main/default/classes/queue/QueueableBuilder.cls index ad18ba0..b26dac8 100644 --- a/force-app/main/default/classes/queue/QueueableBuilder.cls +++ b/force-app/main/default/classes/queue/QueueableBuilder.cls @@ -1,5 +1,6 @@ public inherited sharing class QueueableBuilder { private QueueableJob job; + private String lastChainedCustomJobId; public QueueableBuilder(QueueableJob job) { this.job = job.clone(); @@ -8,6 +9,25 @@ public inherited sharing class QueueableBuilder { public QueueableBuilder() { } + public QueueableBuilder dependsOn(Async.Dependency dependency) { + if (dependency == null || dependency.requiredOutcome == null) { + throw new IllegalArgumentException(QueueableManager.ERROR_MESSAGE_INVALID_DEPENDENCY); + } + if (dependency.previous) { + if (lastChainedCustomJobId == null) { + throw new IllegalArgumentException( + QueueableManager.ERROR_MESSAGE_DEPENDS_ON_PREVIOUS_WITHOUT_JOB + ); + } + dependency.resolvedTargetCustomJobId = lastChainedCustomJobId; + } + if (job.dependencies == null) { + job.dependencies = new List(); + } + job.dependencies.add(dependency); + return this; + } + public QueueableBuilder asyncOptions(AsyncOptions asyncOptions) { if (job.delay != null) { throw new IllegalArgumentException( @@ -55,6 +75,40 @@ public inherited sharing class QueueableBuilder { return this; } + public QueueableBuilder retry(Integer maxRetries) { + if (maxRetries == null || maxRetries < 0) { + throw new IllegalArgumentException(QueueableManager.ERROR_MESSAGE_INVALID_MAX_RETRIES); + } + if (maxRetries > QueueableManager.MAX_RETRY_CAP) { + throw new IllegalArgumentException( + QueueableManager.ERROR_MESSAGE_MAX_RETRIES_EXCEEDS_CAP + ); + } + job.maxRetries = maxRetries; + return this; + } + + public QueueableBuilder backoff(Backoff backoff) { + job.backoff = backoff; + return this; + } + + public QueueableBuilder retryOn(Type exceptionType) { + return retryOn(new List{ exceptionType }); + } + + public QueueableBuilder retryOn(List exceptionTypes) { + if (job.retryOnExceptionTypes == null) { + job.retryOnExceptionTypes = new Set(); + } + for (Type exceptionType : exceptionTypes) { + if (exceptionType != null) { + job.retryOnExceptionTypes.add(exceptionType.getName()); + } + } + return this; + } + public QueueableBuilder mockId(String mockId) { job.mockId = mockId; return this; @@ -62,7 +116,7 @@ public inherited sharing class QueueableBuilder { public QueueableBuilder chain(QueueableJob job) { if (this.job != null) { - chain(); + lastChainedCustomJobId = chain().customJobId; } this.job = job; return this; diff --git a/force-app/main/default/classes/queue/QueueableChain.cls b/force-app/main/default/classes/queue/QueueableChain.cls index 9c8abb5..db02497 100644 --- a/force-app/main/default/classes/queue/QueueableChain.cls +++ b/force-app/main/default/classes/queue/QueueableChain.cls @@ -1,7 +1,7 @@ /** * PMD False Positives: * - CognitiveComplexity: This was intended to have all the logic in one class -**/ + **/ @SuppressWarnings('PMD.CognitiveComplexity') public inherited sharing class QueueableChain { @TestVisible @@ -11,6 +11,9 @@ public inherited sharing class QueueableChain { private Boolean isChainedContext = false; private Integer chainCounter = 0; private QueueableJob currentJob; + private Map outcomeByCustomJobId = new Map(); + private Map resultIdByCustomJobId = new Map(); + private String chainId; @TestVisible private Map queueableJobSettingByJobName { @@ -56,40 +59,192 @@ public inherited sharing class QueueableChain { System.debug(currentJob.uniqueName); currentJob.work(); } catch (Exception ex) { - if (!currentJob.continueOnJobExecuteFail) { - throw ex; - } + // Capture as Strings so the failure survives serialization into the finalizer. + currentJob.hasFailed = true; + currentJob.failedExceptionType = ex.getTypeName(); + currentJob.failedExceptionMessage = ex.getMessage(); if (currentJob.rollbackOnJobExecuteFail) { Database.rollback(sp); Database.releaseSavepoint(sp); + } else if (!currentJob.continueOnJobExecuteFail) { + throw ex; } } } public void enqueueNextJobIfAnyFromFinalizer(FinalizerContext ctx) { QueueableJob previousJob = currentJob; + reconcileOutcomeFromFinalizer(previousJob, ctx); + + if (previousJob.canRetry()) { + enqueueRetry(previousJob); + return; + } + + if (previousJob.hasFailed && previousJob.maxRetries > 0) { + previousJob.recordRetryHistory(previousJob.retryAttempt + 1, null); + } + previousJob.isProcessed = true; previousJob.finalizerCtx = ctx; - - createJobResultIfEnabled(previousJob); + recordOutcome(previousJob); + setFinalizerContextToAllFinalizersForPreviousJob(previousJob, ctx); enqueueNextJobIfAny(); } - public void createJobResultIfEnabled(QueueableJob previousJob) { - if (queueableJobSettingByJobName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL)?.CreateResult__c == true - || queueableJobSettingByJobName.get(previousJob.className)?.CreateResult__c == true - ) { - previousJob.createAsyncResult(); + private void reconcileOutcomeFromFinalizer(QueueableJob job, FinalizerContext ctx) { + if (ctx?.getResult() != ParentJobResult.UNHANDLED_EXCEPTION || job.hasFailed) { + return; + } + job.hasFailed = true; + if (String.isBlank(job.failedExceptionType) && ctx.getException() != null) { + job.failedExceptionType = ctx.getException().getTypeName(); + job.failedExceptionMessage = ctx.getException().getMessage(); + } + } + + private void recordOutcome(QueueableJob job) { + outcomeByCustomJobId.put( + job.customJobId, + job.hasFailed ? Async.Outcome.FAILURE : Async.Outcome.SUCCESS + ); + } + + private void enqueueRetry(QueueableJob previousJob) { + Integer nextAttempt = previousJob.retryAttempt + 1; + Integer delayMinutes = previousJob.backoff?.delayMinutes(nextAttempt); + + previousJob.recordRetryHistory(nextAttempt, delayMinutes); + previousJob.isProcessed = true; + // Only the final attempt produces a result row; suppress this superseded one. + previousJob.resultCreated = true; + + QueueableJob retryJob = previousJob.cloneJob(); + retryJob.retryAttempt = nextAttempt; + retryJob.delay = delayMinutes; + retryJob.asyncOptions = delayMinutes != null ? null : retryJob.asyncOptions; + retryJob.isProcessed = false; + retryJob.resultCreated = false; + retryJob.hasFailed = false; + retryJob.failedExceptionType = null; + retryJob.failedExceptionMessage = null; + retryJob.finalizerCtx = null; + retryJob.retryHistory = previousJob.retryHistory; + + jobs.add(0, retryJob); + enqueueNextJobIfAny(); + } + + private Boolean resultEnabledFor(QueueableJob job) { + return queueableJobSettingByJobName.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL) + ?.CreateResult__c == true || + queueableJobSettingByJobName.get(job.className)?.CreateResult__c == true; + } + + private void createPendingResults() { + List candidates = new List(); + for (QueueableJob job : jobs) { + if (job.isProcessed && !job.resultCreated) { + candidates.add(job); + } } + recordResults(candidates); + } + + private void recordResults(List candidates) { + List toRecord = new List(); + for (QueueableJob job : candidates) { + if (job.resultCreated) { + continue; + } + if (resultEnabledFor(job)) { + toRecord.add(job); + } else { + job.resultCreated = true; + } + } + if (toRecord.isEmpty()) { + return; + } + + List results = new List(); + for (QueueableJob job : toRecord) { + results.add(buildResult(job)); + } + insert results; + for (Integer i = 0; i < toRecord.size(); i++) { + toRecord[i].resultCreated = true; + resultIdByCustomJobId.put(toRecord[i].customJobId, results[i].Id); + } + linkDependencyResults(toRecord); + } + + private AsyncResult__c buildResult(QueueableJob job) { + AsyncResult__c result = new AsyncResult__c( + SalesforceJobId__c = job.salesforceJobId, + CustomJobId__c = job.customJobId, + ChainId__c = job.chainId, + ClassName__c = job.className, + RetryAttempts__c = job.retryAttempt, + RetryHistory__c = job.retryHistory + ); + if (job.skipStatus != null) { + result.Status__c = job.skipStatus; + result.SkipReason__c = job.skipReason; + } else if (job.hasFailed) { + result.Status__c = QueueableManager.STATUS_FAILED; + result.Result__c = job.finalizerCtx?.getResult()?.name(); + result.ExceptionType__c = job.failedExceptionType; + result.ExceptionMessage__c = job.failedExceptionMessage; + } else { + result.Status__c = QueueableManager.STATUS_COMPLETED; + result.Result__c = job.finalizerCtx?.getResult()?.name(); + } + return result; + } + + private void linkDependencyResults(List recordedJobs) { + List toUpdate = new List(); + for (QueueableJob job : recordedJobs) { + Async.Dependency decisive = decisiveDependency(job); + Id dependencyResultId = decisive == null + ? null + : resultIdByCustomJobId.get(decisive.resolvedTargetCustomJobId); + if (dependencyResultId == null) { + continue; + } + Async.Outcome actual = outcomeByCustomJobId.get(decisive.resolvedTargetCustomJobId); + toUpdate.add( + new AsyncResult__c( + Id = resultIdByCustomJobId.get(job.customJobId), + DependsOnResult__c = dependencyResultId, + RequiredOutcome__c = decisive.requiredOutcome.name(), + ActualOutcome__c = actual != null ? actual.name() : 'NOT_RUN' + ) + ); + } + if (!toUpdate.isEmpty()) { + update toUpdate; + } + } + + private Async.Dependency decisiveDependency(QueueableJob job) { + if (job.dependencies == null || job.dependencies.isEmpty()) { + return null; + } + Async.Dependency blocking = firstUnsatisfiedDependency(job); + return blocking != null ? blocking : job.dependencies[0]; } public void enqueueNextJobIfAny() { removeJobsThatAreDisabledAndDependentFinalizers(); + skipJobsWithUnsatisfiedDependencies(); + createPendingResults(); if (!hasNextJob() || (Test.isRunningTest() && System.isQueueable())) { return; } - + QueueableJob nextJob = getNextJobToProcess(); try { isChainedContext = true; @@ -104,22 +259,185 @@ public inherited sharing class QueueableChain { } } + public void stop() { + for (QueueableJob job : jobs) { + if (!job.isProcessed) { + markSkipped( + job, + QueueableManager.STATUS_SKIPPED_CHAIN_STOPPED, + 'Chain stopped via Async.stopChain().' + ); + } + } + } + + public void skipJobByCustomJobId(String customJobId) { + if (!hasJob(customJobId)) { + throw new IllegalArgumentException( + String.format( + QueueableManager.ERROR_MESSAGE_UNKNOWN_JOB, + new List{ customJobId } + ) + ); + } + Set customJobIdsToSkip = getAllCustomJobIdsToRemove(new Set{ customJobId }); + for (QueueableJob job : jobs) { + if (customJobIdsToSkip.contains(job.customJobId) && !job.isProcessed) { + markSkipped( + job, + QueueableManager.STATUS_SKIPPED_EXPLICIT, + 'Skipped via Async.skipJob().' + ); + } + } + } + + private Boolean hasJob(String customJobId) { + for (QueueableJob job : jobs) { + if (job.customJobId == customJobId) { + return true; + } + } + return false; + } + + private void markSkipped(QueueableJob job, String status, String reason) { + job.isProcessed = true; + job.skipStatus = status; + job.skipReason = reason; + } + + private void skipJobsWithUnsatisfiedDependencies() { + QueueableJob candidate = getNextJobToProcess(); + while (candidate != null && firstUnsatisfiedDependency(candidate) != null) { + markSkipped( + candidate, + QueueableManager.STATUS_SKIPPED_DEPENDENCY, + buildDependencySkipReason(candidate) + ); + candidate = getNextJobToProcess(); + } + } + + private Async.Dependency firstUnsatisfiedDependency(QueueableJob job) { + if (job.dependencies == null) { + return null; + } + for (Async.Dependency dependency : job.dependencies) { + Async.Outcome actual = outcomeByCustomJobId.get(dependency.resolvedTargetCustomJobId); + if (!outcomeMatches(actual, dependency.requiredOutcome)) { + return dependency; + } + } + return null; + } + + private String buildDependencySkipReason(QueueableJob job) { + Async.Dependency blocking = firstUnsatisfiedDependency(job); + Async.Outcome actual = outcomeByCustomJobId.get(blocking.resolvedTargetCustomJobId); + return 'Dependency \'' + + labelForCustomJobId(blocking.resolvedTargetCustomJobId) + + '\' required ' + + blocking.requiredOutcome.name() + + ' but was ' + + (actual != null ? actual.name() : 'NOT_RUN'); + } + + private String labelForCustomJobId(String customJobId) { + for (QueueableJob job : jobs) { + if (job.customJobId == customJobId) { + return job.className; + } + } + return customJobId; + } + + private Boolean outcomeMatches(Async.Outcome actual, Async.Outcome required) { + if (actual == null) { + return false; + } + return required == Async.Outcome.COMPLETED || actual == required; + } + public Boolean hasNextJob() { return getNextJobToProcess() != null; } public void addJob(QueueableJob job) { job.setMainAttributes(); + if (chainId == null) { + chainId = UUID.randomUUID().toString(); + } + job.chainId = chainId; + applyRetryDefaultsIfNeeded(job); jobs.add(job); jobs.sort(); } + @TestVisible + private void applyRetryDefaultsIfNeeded(QueueableJob job) { + if (job.maxRetries > 0) { + // Explicit fluent .retry(...) wins over CMDT defaults. + return; + } + + QueueableJobSetting__mdt setting = resolveRetrySetting(job.className); + if (setting == null) { + return; + } + + Integer settingMaxRetries = setting.MaxRetries__c.intValue(); + if (settingMaxRetries > QueueableManager.MAX_RETRY_CAP) { + throw new IllegalArgumentException( + QueueableManager.ERROR_MESSAGE_MAX_RETRIES_EXCEEDS_CAP + ); + } + job.maxRetries = settingMaxRetries; + + if (job.backoff == null && String.isNotBlank(setting.BackoffStrategy__c)) { + Integer baseMinutes = setting.BackoffBaseMinutes__c?.intValue() ?? 1; + job.backoff = Backoff.fromName(setting.BackoffStrategy__c, baseMinutes); + } + + if (job.retryOnExceptionTypes == null) { + job.retryOnExceptionTypes = parseExceptionTypes(setting.RetryableExceptions__c); + } + } + + private QueueableJobSetting__mdt resolveRetrySetting(String jobClassName) { + Map jobSettings = queueableJobSettingByJobName; + QueueableJobSetting__mdt setting = jobSettings.get(jobClassName); + if (setting == null || setting.MaxRetries__c == null) { + setting = jobSettings.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL); + } + Boolean hasRetryDefault = + setting != null && + setting.MaxRetries__c != null && + setting.MaxRetries__c > 0; + return hasRetryDefault ? setting : null; + } + + private Set parseExceptionTypes(String csv) { + if (String.isBlank(csv)) { + return null; + } + Set types = new Set(); + for (String exceptionType : csv.split(',')) { + if (String.isNotBlank(exceptionType)) { + types.add(exceptionType.trim()); + } + } + return types.isEmpty() ? null : types; + } + public List getJobs() { return jobs; } public void executeOrReplaceInitialQueueableChainSchedulableJob() { - QueueableChainSchedulable.removeInitialQueuableChainSchedulableIfExists(initialQueuableChainSchedulableId); + QueueableChainSchedulable.removeInitialQueuableChainSchedulableIfExists( + initialQueuableChainSchedulableId + ); Datetime nextRunTime = Datetime.now().addMinutes(1); String hour = String.valueOf(nextRunTime.hour()); @@ -127,7 +445,14 @@ public inherited sharing class QueueableChain { String ss = String.valueOf(nextRunTime.second()); String nextFireTime = ss + ' ' + min + ' ' + hour + ' * * ?'; - initialQueuableChainSchedulableId = System.schedule('QueueableChainSchedulable/' + String.valueOf(Datetime.now()) + '/' + UUID.randomUUID().toString(), nextFireTime, new QueueableChainSchedulable(this)); + initialQueuableChainSchedulableId = System.schedule( + 'QueueableChainSchedulable/' + + String.valueOf(Datetime.now()) + + '/' + + UUID.randomUUID().toString(), + nextFireTime, + new QueueableChainSchedulable(this) + ); } public QueueableJob getCurrentJob() { @@ -151,10 +476,13 @@ public inherited sharing class QueueableChain { return; } - QueueableJobSetting__mdt allJobSetting = jobSettings.get(QueueableManager.QUEUEABLE_JOB_SETTING_ALL); + QueueableJobSetting__mdt allJobSetting = jobSettings.get( + QueueableManager.QUEUEABLE_JOB_SETTING_ALL + ); - if(allJobSetting?.IsDisabled__c == true) { + if (allJobSetting?.IsDisabled__c == true) { // If the global setting is disabled, clear all jobs + markDisabledAndRecord(new List(jobs)); jobs.clear(); return; } @@ -173,15 +501,35 @@ public inherited sharing class QueueableChain { return; } customJobIdsToRemove = getAllCustomJobIdsToRemove(customJobIdsToRemove); - - for (Integer i = jobs.size() - 1; i >= 0; i--) { - QueueableJob job = jobs[i]; + + List disabledJobs = new List(); + for (QueueableJob job : jobs) { if (customJobIdsToRemove.contains(job.customJobId)) { + disabledJobs.add(job); + } + } + markDisabledAndRecord(disabledJobs); + + for (Integer i = jobs.size() - 1; i >= 0; i--) { + if (customJobIdsToRemove.contains(jobs[i].customJobId)) { jobs.remove(i); } } } + private void markDisabledAndRecord(List disabledJobs) { + for (QueueableJob job : disabledJobs) { + if (!job.isProcessed) { + markSkipped( + job, + QueueableManager.STATUS_SKIPPED_DISABLED, + 'Disabled via QueueableJobSetting__mdt.' + ); + } + } + recordResults(disabledJobs); + } + private Set getAllCustomJobIdsToRemove(Set customJobIdsToRemove) { Set dependentCustomJobIdsToRemove = new Set(); for (QueueableJob job : jobs) { @@ -189,7 +537,7 @@ public inherited sharing class QueueableChain { dependentCustomJobIdsToRemove.add(job.customJobId); } } - + if (dependentCustomJobIdsToRemove.isEmpty()) { return customJobIdsToRemove; } else { @@ -207,9 +555,16 @@ public inherited sharing class QueueableChain { return null; } - private void setFinalizerContextToAllFinalizersForPreviousJob(QueueableJob previousJob, FinalizerContext ctx) { + private void setFinalizerContextToAllFinalizersForPreviousJob( + QueueableJob previousJob, + FinalizerContext ctx + ) { for (QueueableJob job : jobs) { - if (!job.isProcessed && job.parentCustomJobId == previousJob.customJobId && job.finalizerCtx == null) { + if ( + !job.isProcessed && + job.parentCustomJobId == previousJob.customJobId && + job.finalizerCtx == null + ) { injectFinalizerMockOrDefault(job, ctx); } } diff --git a/force-app/main/default/classes/queue/QueueableJob.cls b/force-app/main/default/classes/queue/QueueableJob.cls index f511a52..f878cdf 100644 --- a/force-app/main/default/classes/queue/QueueableJob.cls +++ b/force-app/main/default/classes/queue/QueueableJob.cls @@ -19,6 +19,21 @@ public abstract class QueueableJob implements Queueable, Comparable { public Boolean deepClone = false; public QueueableContext queueableCtx; + public List dependencies; + public String chainId; + public String skipStatus; + public String skipReason; + public Boolean resultCreated = false; + + public Integer maxRetries = 0; + public Integer retryAttempt = 0; + public Backoff backoff; + public Set retryOnExceptionTypes; + public Boolean hasFailed = false; + public String failedExceptionType; + public String failedExceptionMessage; + public String retryHistory; + public String parentCustomJobId; public FinalizerContext finalizerCtx; public String mockId; @@ -76,12 +91,38 @@ public abstract class QueueableJob implements Queueable, Comparable { this.uniqueName = this.uniqueName + '::' + String.valueOf(chainCounter); } - public void createAsyncResult() { - insert new AsyncResult__c( - SalesforceJobId__c = salesforceJobId, - CustomJobId__c = customJobId, - Result__c = finalizerCtx.getResult().toString() - ); + public Boolean canRetry() { + return hasFailed && retryAttempt < maxRetries && isRetryableException(); + } + + private Boolean isRetryableException() { + if (String.isBlank(failedExceptionType)) { + return false; + } + if (retryOnExceptionTypes == null || retryOnExceptionTypes.isEmpty()) { + return true; + } + for (String allowedType : retryOnExceptionTypes) { + if ( + failedExceptionType.equalsIgnoreCase(allowedType) || + failedExceptionType.endsWithIgnoreCase('.' + allowedType) + ) { + return true; + } + } + return false; + } + + public void recordRetryHistory(Integer attemptNumber, Integer delayMinutes) { + String line = + 'Attempt ' + + attemptNumber + + ': ' + + failedExceptionType + + ' - ' + + failedExceptionMessage + + (delayMinutes != null ? ' (retry in ' + delayMinutes + 'm)' : ' (no further retry)'); + retryHistory = String.isBlank(retryHistory) ? line : retryHistory + '\n' + line; } public QueueableJob cloneJob() { @@ -90,10 +131,12 @@ public abstract class QueueableJob implements Queueable, Comparable { 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); }' + '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); }' ); } } diff --git a/force-app/main/default/classes/queue/QueueableManager.cls b/force-app/main/default/classes/queue/QueueableManager.cls index b7877cc..378f4a4 100644 --- a/force-app/main/default/classes/queue/QueueableManager.cls +++ b/force-app/main/default/classes/queue/QueueableManager.cls @@ -1,5 +1,18 @@ public inherited sharing class QueueableManager { public static final String QUEUEABLE_JOB_SETTING_ALL = 'All'; + public static final String STATUS_COMPLETED = 'COMPLETED'; + public static final String STATUS_FAILED = 'FAILED'; + public static final String STATUS_SKIPPED_DEPENDENCY = 'SKIPPED_DEPENDENCY'; + public static final String STATUS_SKIPPED_CHAIN_STOPPED = 'SKIPPED_CHAIN_STOPPED'; + public static final String STATUS_SKIPPED_EXPLICIT = 'SKIPPED_EXPLICIT'; + public static final String STATUS_SKIPPED_DISABLED = 'SKIPPED_DISABLED'; + public static final Integer MAX_RETRY_CAP = 10; + public static final String ERROR_MESSAGE_INVALID_MAX_RETRIES = 'Max retries must be a non-negative integer'; + public static final String ERROR_MESSAGE_MAX_RETRIES_EXCEEDS_CAP = + 'Max retries cannot exceed the framework cap of ' + MAX_RETRY_CAP; + public static final String ERROR_MESSAGE_INVALID_DEPENDENCY = 'A dependency requires a target and an outcome (e.g. Async.after(result).succeeded())'; + public static final String ERROR_MESSAGE_DEPENDS_ON_PREVIOUS_WITHOUT_JOB = 'dependsOn(Async.afterPrevious()) requires a previously chained job'; + public static final String ERROR_MESSAGE_UNKNOWN_JOB = 'No job in this chain has custom job id "{0}".'; public static final String ERROR_MESSAGE_ASYNC_OPTIONS_AFTER_DELAY = 'Cannot set asyncOptions after delay has been set'; public static final String ERROR_MESSAGE_DELAY_AFTER_ASYNC_OPTIONS = 'Cannot set delay after asyncOptions has been set'; private static final String ERROR_MESSAGE_CHAIN_NULL = 'QueueableChain cannot be null'; @@ -28,6 +41,14 @@ public inherited sharing class QueueableManager { return chain; } + public void stopChain() { + chain.stop(); + } + + public void skipJob(String customJobId) { + chain.skipJobByCustomJobId(customJobId); + } + public Async.QueueableJobContext getQueueableJobContext() { Async.QueueableJobContext ctx = new Async.QueueableJobContext(); ctx.currentJob = chain.getCurrentJob(); diff --git a/force-app/main/default/objects/AsyncResult__c/fields/ActualOutcome__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/ActualOutcome__c.field-meta.xml new file mode 100644 index 0000000..a1bcd5b --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/ActualOutcome__c.field-meta.xml @@ -0,0 +1,30 @@ + + + ActualOutcome__c + The outcome the decisive dependency actually had (NOT_RUN if the dependency never ran). + + false + false + Picklist + + true + + false + + SUCCESS + false + + + + FAILURE + false + + + + NOT_RUN + false + + + + + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/ChainId__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/ChainId__c.field-meta.xml new file mode 100644 index 0000000..e29f5f6 --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/ChainId__c.field-meta.xml @@ -0,0 +1,12 @@ + + + ChainId__c + Correlation id shared by every job result in the same chain run. Query by this to see the whole run as a timeline. + true + + 36 + false + false + Text + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/ClassName__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/ClassName__c.field-meta.xml new file mode 100644 index 0000000..6f40c3b --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/ClassName__c.field-meta.xml @@ -0,0 +1,11 @@ + + + ClassName__c + The Apex class name of the job. + + 255 + false + false + Text + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/DependsOnResult__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/DependsOnResult__c.field-meta.xml new file mode 100644 index 0000000..00f9962 --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/DependsOnResult__c.field-meta.xml @@ -0,0 +1,11 @@ + + + DependsOnResult__c + The result of the dependency that decided this job's fate (for a skip, the dependency that blocked it). Navigate here to see what this job was waiting on. + + AsyncResult__c + Dependent Results + DependentResults + false + Lookup + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/ExceptionMessage__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/ExceptionMessage__c.field-meta.xml new file mode 100644 index 0000000..276a0be --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/ExceptionMessage__c.field-meta.xml @@ -0,0 +1,11 @@ + + + ExceptionMessage__c + The exception message that caused the job to fail, when Status is Failed. + + 32768 + false + false + LongTextArea + 5 + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/ExceptionType__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/ExceptionType__c.field-meta.xml new file mode 100644 index 0000000..fe94451 --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/ExceptionType__c.field-meta.xml @@ -0,0 +1,11 @@ + + + ExceptionType__c + The exception type that caused the job to fail, when Status is Failed. + + 255 + false + false + Text + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/RequiredOutcome__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/RequiredOutcome__c.field-meta.xml new file mode 100644 index 0000000..bb259ce --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/RequiredOutcome__c.field-meta.xml @@ -0,0 +1,30 @@ + + + RequiredOutcome__c + The outcome the decisive dependency was required to have for this job to run. + + false + false + Picklist + + true + + false + + SUCCESS + false + + + + FAILURE + false + + + + COMPLETED + false + + + + + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/RetryAttempts__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/RetryAttempts__c.field-meta.xml new file mode 100644 index 0000000..f94144d --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/RetryAttempts__c.field-meta.xml @@ -0,0 +1,11 @@ + + + RetryAttempts__c + Number of retries the job went through (0 means it ran only once). + + 3 + false + 0 + Number + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/RetryHistory__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/RetryHistory__c.field-meta.xml new file mode 100644 index 0000000..f492698 --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/RetryHistory__c.field-meta.xml @@ -0,0 +1,11 @@ + + + RetryHistory__c + Per-attempt retry log (attempt number, exception, computed delay) aggregated for a job that exhausted its retries. Empty when the job did not retry. + + 32768 + false + false + LongTextArea + 5 + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/SkipReason__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/SkipReason__c.field-meta.xml new file mode 100644 index 0000000..edadab6 --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/SkipReason__c.field-meta.xml @@ -0,0 +1,11 @@ + + + SkipReason__c + Human-readable explanation of why the job was skipped, when Status is one of the Skipped values. + + 255 + false + false + Text + false + diff --git a/force-app/main/default/objects/AsyncResult__c/fields/Status__c.field-meta.xml b/force-app/main/default/objects/AsyncResult__c/fields/Status__c.field-meta.xml new file mode 100644 index 0000000..633ef9f --- /dev/null +++ b/force-app/main/default/objects/AsyncResult__c/fields/Status__c.field-meta.xml @@ -0,0 +1,45 @@ + + + Status__c + Lifecycle outcome of the job in the chain: whether it ran (completed/failed) or was skipped and why. + + false + false + Picklist + + true + + false + + COMPLETED + false + + + + FAILED + false + + + + SKIPPED_DEPENDENCY + false + + + + SKIPPED_CHAIN_STOPPED + false + + + + SKIPPED_EXPLICIT + false + + + + SKIPPED_DISABLED + false + + + + + diff --git a/force-app/main/default/objects/QueueableJobSetting__mdt/fields/BackoffBaseMinutes__c.field-meta.xml b/force-app/main/default/objects/QueueableJobSetting__mdt/fields/BackoffBaseMinutes__c.field-meta.xml new file mode 100644 index 0000000..5768f40 --- /dev/null +++ b/force-app/main/default/objects/QueueableJobSetting__mdt/fields/BackoffBaseMinutes__c.field-meta.xml @@ -0,0 +1,12 @@ + + + BackoffBaseMinutes__c + Base delay in minutes for the backoff strategy. Effective delay is clamped to 0-10 minutes (System.enqueueJob limit). + DeveloperControlled + + 2 + false + 0 + Number + false + diff --git a/force-app/main/default/objects/QueueableJobSetting__mdt/fields/BackoffStrategy__c.field-meta.xml b/force-app/main/default/objects/QueueableJobSetting__mdt/fields/BackoffStrategy__c.field-meta.xml new file mode 100644 index 0000000..c1c67b0 --- /dev/null +++ b/force-app/main/default/objects/QueueableJobSetting__mdt/fields/BackoffStrategy__c.field-meta.xml @@ -0,0 +1,11 @@ + + + BackoffStrategy__c + Default backoff strategy for the matching job: FIXED, EXPONENTIAL, or EXPONENTIAL_JITTER. Blank means immediate re-enqueue. + DeveloperControlled + + false + Text + 30 + false + diff --git a/force-app/main/default/objects/QueueableJobSetting__mdt/fields/MaxRetries__c.field-meta.xml b/force-app/main/default/objects/QueueableJobSetting__mdt/fields/MaxRetries__c.field-meta.xml new file mode 100644 index 0000000..57e396f --- /dev/null +++ b/force-app/main/default/objects/QueueableJobSetting__mdt/fields/MaxRetries__c.field-meta.xml @@ -0,0 +1,12 @@ + + + MaxRetries__c + Default maximum number of retries for the matching job (overridden by the fluent .retry(n) API). 0 or blank disables retry. + DeveloperControlled + + 2 + false + 0 + Number + false + diff --git a/force-app/main/default/objects/QueueableJobSetting__mdt/fields/RetryableExceptions__c.field-meta.xml b/force-app/main/default/objects/QueueableJobSetting__mdt/fields/RetryableExceptions__c.field-meta.xml new file mode 100644 index 0000000..daf9955 --- /dev/null +++ b/force-app/main/default/objects/QueueableJobSetting__mdt/fields/RetryableExceptions__c.field-meta.xml @@ -0,0 +1,11 @@ + + + RetryableExceptions__c + Comma-separated exception type names to retry on (e.g. System.DmlException,System.CalloutException). Blank retries on any exception except System.LimitException. + DeveloperControlled + + 255 + false + Text + false + diff --git a/force-app/main/default/permissionsets/AsyncResultAccess.permissionset-meta.xml b/force-app/main/default/permissionsets/AsyncResultAccess.permissionset-meta.xml new file mode 100644 index 0000000..6a6774e --- /dev/null +++ b/force-app/main/default/permissionsets/AsyncResultAccess.permissionset-meta.xml @@ -0,0 +1,85 @@ + + + + Read access to AsyncResult__c records and all their fields, so admins and reports can see async job outcomes. The framework writes these records in system context and does not require this set. + false + + AsyncResult__c + true + false + false + false + true + false + + + AsyncResult__c.ActualOutcome__c + true + false + + + AsyncResult__c.ChainId__c + true + false + + + AsyncResult__c.ClassName__c + true + false + + + AsyncResult__c.CustomJobId__c + true + false + + + AsyncResult__c.DependsOnResult__c + true + false + + + AsyncResult__c.ExceptionMessage__c + true + false + + + AsyncResult__c.ExceptionType__c + true + false + + + AsyncResult__c.RequiredOutcome__c + true + false + + + AsyncResult__c.Result__c + true + false + + + AsyncResult__c.RetryAttempts__c + true + false + + + AsyncResult__c.RetryHistory__c + true + false + + + AsyncResult__c.SalesforceJobId__c + true + false + + + AsyncResult__c.SkipReason__c + true + false + + + AsyncResult__c.Status__c + true + false + + diff --git a/website/api/queueable.md b/website/api/queueable.md index aa96524..c837760 100644 --- a/website/api/queueable.md +++ b/website/api/queueable.md @@ -58,6 +58,10 @@ The following are methods for using Async with Queueable jobs: - [`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) - [`chain()`](#chain) - [`chain(QueueableJob job)`](#chain-next-job) @@ -75,6 +79,11 @@ The following are methods for using Async with Queueable jobs: - [`getQueueableChainSchedulableId()`](#getqueueablechainschedulableid) - [`getCurrentQueueableChainState()`](#getcurrentqueueablechainstate) +[**Chain control**](#chain-control) + +- [`stopChain()`](#stopchain) +- [`skipJob(String customJobId)`](#skipjob) + ### INIT #### queueable @@ -197,7 +206,19 @@ Async.queueable(new MyQueueableJob()) #### continueOnJobExecuteFail -Allows the job chain to continue even if this job fails during execution. +Controls what happens to **this job's own work** when `work()` throws. It does +**not** control the chain. Remaining jobs 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**. + +In both cases the job is still recorded as failed for +[`dependsOn(...)`](#dependson) outcome checks, and any [`retry(...)`](#retry) +still applies. **Signature** @@ -214,7 +235,11 @@ Async.queueable(new MyQueueableJob()) #### rollbackOnJobExecuteFail -Rolls back any DML operations if this job fails during execution. +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()`. **Signature** @@ -229,6 +254,149 @@ 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`; +a higher value (whether passed to `retry(...)` or configured via +`QueueableJobSetting__mdt`) throws an exception. + +On each failed attempt the framework re-enqueues a fresh clone of the job with +an incremented attempt counter. By default **every** exception is retried. As a +best practice, narrow retries to the failures you know are worth re-running with +[`retryOn(...)`](#retryon). Retry composes with `continueOnJobExecuteFail`: once +retries are exhausted the chain behaves exactly as it would for a non-retry job. + +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. For jobs carrying mutable member state, combine with +[`deepClone()`](#deepclone). ::: + +**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(); +``` + +#### retryOn + +Restricts retry to the listed exception types. Call multiple times to add more. +When omitted, retry applies to any exception. + +**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 @@ -469,3 +637,64 @@ QueueableChainState currentChain = Async.getCurrentQueueableChainState(); | `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. + +**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. + +**Signature** + +```apex +void skipJob(String customJobId); +``` + +**Example** + +```apex +Async.skipJob(notificationsResult.customJobId); +``` diff --git a/website/getting-started.md b/website/getting-started.md index 0990993..9222047 100644 --- a/website/getting-started.md +++ b/website/getting-started.md @@ -227,15 +227,31 @@ Available settings: ## Async Result Records When enabled in `QueueableJobSetting__mdt` (**CreateResult\_\_c** = true), Async -Lib automatically creates `AsyncResult__c` records for executed jobs. These -records help track job execution and outcomes. +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_EXPLICIT`, + `SKIPPED_DISABLED` - **CustomJobId\_\_c**: Unique job ID generated by Async Lib - **SalesforceJobId\_\_c**: Underlying Salesforce job ID (Queueable, Batch, or Scheduled) -- **Result\_\_c**: Final job status (SUCCESS, UNHANDLED_EXCEPTION) +- **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 ## What's Next? From dc764f0a20901aca8e052b53729144e53b961ecd Mon Sep 17 00:00:00 2001 From: Mateusz7410 Date: Mon, 1 Jun 2026 12:49:29 +0000 Subject: [PATCH 2/2] Add isRetryable and resetForRetry hooks to Queueable retry Let jobs classify failures per-exception and reset transient state before a retry, addressing cases where type-only filtering and shallow cloning fall short. - isRetryable(Exception): overridable veto evaluated where the live exception exists (catch site for handled failures, finalizer getException() for uncatchable ones). AND-composed with retryOn(types): both gates must pass. - resetForRetry(): overridable hook run on the retry clone to recreate or clear transient state (e.g. a Unit of Work) that a shallow clone would otherwise carry over. - Replace failedExceptionType/Message strings with a FailureInfo value; the retry decision is now a stored boolean, the captured metadata is audit-only. - Null-exception fallback: retry only when no retryOn filter is set; a throwing override is treated as not-retryable and recorded in RetryHistory. --- force-app/main/default/classes/AsyncTest.cls | 220 ++++++++++++++++-- .../default/classes/queue/QueueableChain.cls | 27 ++- .../default/classes/queue/QueueableJob.cls | 105 ++++++--- website/api/queueable.md | 113 ++++++++- 4 files changed, 394 insertions(+), 71 deletions(-) diff --git a/force-app/main/default/classes/AsyncTest.cls b/force-app/main/default/classes/AsyncTest.cls index 8f7ef24..a510e67 100644 --- a/force-app/main/default/classes/AsyncTest.cls +++ b/force-app/main/default/classes/AsyncTest.cls @@ -1831,63 +1831,56 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldRetryOnAnyExceptionByDefault() { QueueableJobTest1 job = new QueueableJobTest1(); - job.hasFailed = true; - job.failedExceptionType = 'System.DmlException'; job.maxRetries = 2; - job.retryAttempt = 0; + job.recordFailure(new CustomException('boom')); Assert.isTrue(job.canRetry()); + Assert.areEqual('boom', job.failure.message, 'Failure metadata is captured.'); } @IsTest - private static void shouldRetryLimitExceptionByDefault() { + private static void shouldMatchAnyTypeWhenNoFilter() { QueueableJobTest1 job = new QueueableJobTest1(); - job.hasFailed = true; - job.failedExceptionType = 'System.LimitException'; - job.maxRetries = 3; - Assert.isTrue(job.canRetry(), 'With no retryOn filter, every exception is retried.'); + Assert.isTrue( + job.matchesRetryOnTypes('System.LimitException'), + 'With no retryOn filter, every type matches.' + ); } @IsTest - private static void shouldRetryLimitExceptionWhenWhitelisted() { + private static void shouldMatchWhitelistedType() { QueueableJobTest1 job = new QueueableJobTest1(); - job.hasFailed = true; - job.failedExceptionType = 'System.LimitException'; - job.maxRetries = 3; job.retryOnExceptionTypes = new Set{ 'System.LimitException' }; - Assert.isTrue(job.canRetry()); + Assert.isTrue(job.matchesRetryOnTypes('System.LimitException')); } @IsTest - private static void shouldNotRetryExceptionOutsideWhitelist() { + private static void shouldNotMatchTypeOutsideWhitelist() { QueueableJobTest1 job = new QueueableJobTest1(); - job.hasFailed = true; - job.failedExceptionType = 'System.CalloutException'; job.maxRetries = 3; job.retryOnExceptionTypes = new Set{ 'System.DmlException' }; - Assert.isFalse(job.canRetry()); + Assert.isFalse(job.matchesRetryOnTypes('System.CalloutException')); + + job.recordFailure(new CustomException('outside whitelist')); + Assert.isFalse(job.canRetry(), 'Type outside the allowlist must not retry.'); } @IsTest - private static void shouldRetryExceptionMatchingShortName() { + private static void shouldMatchTypeByShortName() { QueueableJobTest1 job = new QueueableJobTest1(); - job.hasFailed = true; - job.failedExceptionType = 'System.DmlException'; - job.maxRetries = 3; job.retryOnExceptionTypes = new Set{ 'DmlException' }; - Assert.isTrue(job.canRetry()); + Assert.isTrue(job.matchesRetryOnTypes('System.DmlException')); } @IsTest private static void shouldNotRetryWhenAttemptsExhausted() { QueueableJobTest1 job = new QueueableJobTest1(); - job.hasFailed = true; - job.failedExceptionType = 'System.DmlException'; job.maxRetries = 2; + job.recordFailure(new CustomException('boom')); job.retryAttempt = 2; Assert.isFalse(job.canRetry()); @@ -1896,12 +1889,155 @@ private class AsyncTest implements Database.Batchable { @IsTest private static void shouldNotRetryWhenNotFailed() { QueueableJobTest1 job = new QueueableJobTest1(); - job.hasFailed = false; job.maxRetries = 2; Assert.isFalse(job.canRetry()); } + @IsTest + private static void shouldVetoRetryViaIsRetryableOverride() { + NonRetryableJob job = new NonRetryableJob(); + job.maxRetries = 3; + job.recordFailure(new CustomException('boom')); + + Assert.isFalse(job.retryDecision, 'Override vetoed the retry.'); + Assert.isFalse(job.canRetry()); + } + + @IsTest + private static void shouldRequireBothRetryOnAndIsRetryable() { + MessageVetoJob typeMatchedButVetoed = new MessageVetoJob(); + typeMatchedButVetoed.maxRetries = 2; + typeMatchedButVetoed.retryOnExceptionTypes = new Set{ 'CustomException' }; + typeMatchedButVetoed.recordFailure(new CustomException('permanent failure')); + Assert.isFalse( + typeMatchedButVetoed.canRetry(), + 'Type matched but isRetryable vetoed by message — AND blocks.' + ); + + MessageVetoJob bothPass = new MessageVetoJob(); + bothPass.maxRetries = 2; + bothPass.retryOnExceptionTypes = new Set{ 'CustomException' }; + bothPass.recordFailure(new CustomException('transient blip')); + Assert.isTrue(bothPass.canRetry(), 'Both gates pass.'); + + MessageVetoJob typeExcluded = new MessageVetoJob(); + typeExcluded.maxRetries = 2; + typeExcluded.retryOnExceptionTypes = new Set{ 'System.DmlException' }; + typeExcluded.recordFailure(new CustomException('transient blip')); + Assert.isFalse( + typeExcluded.canRetry(), + 'retryOn excluded the type; AND can only narrow, never broaden.' + ); + } + + @IsTest + private static void shouldNotRetryWhenIsRetryableOverrideThrows() { + ThrowingClassifierJob job = new ThrowingClassifierJob(); + job.maxRetries = 2; + job.recordFailure(new CustomException('original')); + + Assert.isFalse(job.retryDecision, 'A throwing classifier must not retry.'); + Assert.isFalse(job.canRetry()); + Assert.isTrue( + String.isNotBlank(job.retryHistory), + 'The override failure is recorded in retry history.' + ); + } + + @IsTest + private static void shouldClassifyUncatchableExceptionFromFinalizer() { + SuccessfulQueueableTest job = new SuccessfulQueueableTest(); + QueueableChain chain = new QueueableChain(); + chain.addJob(job); + job.maxRetries = 1; + QueueableManager.get().setChain(chain); + + Test.startTest(); + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + chain.enqueueNextJobIfAnyFromFinalizer( + new AsyncMock.MockFinalizerContext().setException(new CustomException('uncatchable')) + ); + Test.stopTest(); + + Assert.isTrue(job.hasFailed, 'Reconcile marks the job failed from the finalizer.'); + Assert.areEqual( + 'uncatchable', + job.failure.message, + 'Exception captured from the finalizer.' + ); + Assert.areEqual(2, chain.jobs.size(), 'A finalizer-detected failure enqueues a retry.'); + } + + @IsTest + private static void shouldRetryUnknownFailureWhenNoTypeFilter() { + SuccessfulQueueableTest job = new SuccessfulQueueableTest(); + QueueableChain chain = new QueueableChain(); + chain.addJob(job); + job.maxRetries = 1; + QueueableManager.get().setChain(chain); + + Test.startTest(); + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + chain.enqueueNextJobIfAnyFromFinalizer( + new AsyncMock.MockFinalizerContext().setResult(ParentJobResult.UNHANDLED_EXCEPTION) + ); + Test.stopTest(); + + Assert.areEqual( + 2, + chain.jobs.size(), + 'No type filter: an unidentifiable failure is retried by default policy.' + ); + } + + @IsTest + private static void shouldNotRetryUnknownFailureWhenTypeFilterSet() { + SuccessfulQueueableTest job = new SuccessfulQueueableTest(); + QueueableChain chain = new QueueableChain(); + chain.addJob(job); + job.maxRetries = 1; + job.retryOnExceptionTypes = new Set{ 'System.DmlException' }; + QueueableManager.get().setChain(chain); + + Test.startTest(); + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + chain.enqueueNextJobIfAnyFromFinalizer( + new AsyncMock.MockFinalizerContext().setResult(ParentJobResult.UNHANDLED_EXCEPTION) + ); + Test.stopTest(); + + Assert.isTrue(job.hasFailed); + Assert.areEqual( + 1, + chain.jobs.size(), + 'Type filter set but failure unidentifiable: cannot confirm membership, so no retry.' + ); + } + + @IsTest + private static void shouldRunResetForRetryOnRetryClone() { + ResettableRetryJob job = new ResettableRetryJob(); + QueueableChain chain = new QueueableChain(); + chain.addJob(job); + job.maxRetries = 2; + job.continueOnJobExecuteFail = true; + QueueableManager.get().setChain(chain); + + Test.startTest(); + chain.executeCurrentJob(new AsyncMock.MockQueueableContext()); + chain.enqueueNextJobIfAnyFromFinalizer( + new AsyncMock.MockFinalizerContext().setResult(ParentJobResult.SUCCESS) + ); + Test.stopTest(); + + Assert.areEqual(2, chain.jobs.size(), 'A retry clone should have been inserted.'); + Assert.isTrue( + ((ResettableRetryJob) chain.jobs[0]).wasReset, + 'resetForRetry() must run on the retry clone.' + ); + } + @IsTest private static void shouldRejectNegativeRetryCount() { try { @@ -2668,6 +2804,40 @@ private class AsyncTest implements Database.Batchable { } } + private class NonRetryableJob extends QueueableJob { + public override void work() { + } + public override Boolean isRetryable(Exception ex) { + return false; + } + } + + private class MessageVetoJob extends QueueableJob { + public override void work() { + } + public override Boolean isRetryable(Exception ex) { + return !ex.getMessage().containsIgnoreCase('permanent'); + } + } + + private class ThrowingClassifierJob extends QueueableJob { + public override void work() { + } + public override Boolean isRetryable(Exception ex) { + throw new CustomException('classifier blew up'); + } + } + + private class ResettableRetryJob extends QueueableJob { + public Boolean wasReset = false; + public override void work() { + throw new CustomException(AsyncTest.CUSTOM_ERROR_MESSAGE); + } + public override void resetForRetry() { + this.wasReset = true; + } + } + private class QueueableTestFinalizer extends QueueableJob.Finalizer { public override void work() { FinalizerContext finalizerCtx = Async.getQueueableJobContext()?.finalizerCtx; diff --git a/force-app/main/default/classes/queue/QueueableChain.cls b/force-app/main/default/classes/queue/QueueableChain.cls index db02497..37f63d1 100644 --- a/force-app/main/default/classes/queue/QueueableChain.cls +++ b/force-app/main/default/classes/queue/QueueableChain.cls @@ -59,10 +59,9 @@ public inherited sharing class QueueableChain { System.debug(currentJob.uniqueName); currentJob.work(); } catch (Exception ex) { - // Capture as Strings so the failure survives serialization into the finalizer. - currentJob.hasFailed = true; - currentJob.failedExceptionType = ex.getTypeName(); - currentJob.failedExceptionMessage = ex.getMessage(); + // The live exception only exists here; classify now and capture metadata as a + // FailureInfo so the decision + audit fields survive serialization into the finalizer. + currentJob.recordFailure(ex); if (currentJob.rollbackOnJobExecuteFail) { Database.rollback(sp); Database.releaseSavepoint(sp); @@ -97,10 +96,13 @@ public inherited sharing class QueueableChain { if (ctx?.getResult() != ParentJobResult.UNHANDLED_EXCEPTION || job.hasFailed) { return; } - job.hasFailed = true; - if (String.isBlank(job.failedExceptionType) && ctx.getException() != null) { - job.failedExceptionType = ctx.getException().getTypeName(); - job.failedExceptionMessage = ctx.getException().getMessage(); + // Uncatchable failures (e.g. LimitException) never hit the catch in executeCurrentJob; + // the finalizer is the only place their exception is reachable. + Exception ex = ctx.getException(); + if (ex != null) { + job.recordFailure(ex); + } else { + job.recordUnknownFailure(); } } @@ -127,10 +129,11 @@ public inherited sharing class QueueableChain { retryJob.isProcessed = false; retryJob.resultCreated = false; retryJob.hasFailed = false; - retryJob.failedExceptionType = null; - retryJob.failedExceptionMessage = null; + retryJob.failure = null; + retryJob.retryDecision = false; retryJob.finalizerCtx = null; retryJob.retryHistory = previousJob.retryHistory; + retryJob.resetForRetry(); jobs.add(0, retryJob); enqueueNextJobIfAny(); @@ -195,8 +198,8 @@ public inherited sharing class QueueableChain { } else if (job.hasFailed) { result.Status__c = QueueableManager.STATUS_FAILED; result.Result__c = job.finalizerCtx?.getResult()?.name(); - result.ExceptionType__c = job.failedExceptionType; - result.ExceptionMessage__c = job.failedExceptionMessage; + result.ExceptionType__c = job.failure?.type; + result.ExceptionMessage__c = job.failure?.message; } else { result.Status__c = QueueableManager.STATUS_COMPLETED; result.Result__c = job.finalizerCtx?.getResult()?.name(); diff --git a/force-app/main/default/classes/queue/QueueableJob.cls b/force-app/main/default/classes/queue/QueueableJob.cls index f878cdf..6949318 100644 --- a/force-app/main/default/classes/queue/QueueableJob.cls +++ b/force-app/main/default/classes/queue/QueueableJob.cls @@ -30,8 +30,8 @@ public abstract class QueueableJob implements Queueable, Comparable { public Backoff backoff; public Set retryOnExceptionTypes; public Boolean hasFailed = false; - public String failedExceptionType; - public String failedExceptionMessage; + public FailureInfo failure; + public Boolean retryDecision = false; public String retryHistory; public String parentCustomJobId; @@ -47,6 +47,27 @@ public abstract class QueueableJob implements Queueable, Comparable { public abstract void work(); + public virtual Boolean isRetryable(Exception ex) { + return true; + } + + public virtual void resetForRetry() { + } + + public virtual QueueableJob cloneForDeepCopy() { + String fullName = this.className; + Type resolvedType = Type.forName(fullName); + + if (resolvedType == null && fullName.contains('.')) { + resolvedType = Type.forName( + fullName.substringBefore('.'), + fullName.substringAfter('.') + ); + } + + return (QueueableJob) JSON.deserialize(JSON.serialize(this), resolvedType); + } + public void execute(QueueableContext ctx) { chain.execute(ctx); } @@ -92,11 +113,23 @@ public abstract class QueueableJob implements Queueable, Comparable { } public Boolean canRetry() { - return hasFailed && retryAttempt < maxRetries && isRetryableException(); + return hasFailed && retryAttempt < maxRetries && retryDecision; } - private Boolean isRetryableException() { - if (String.isBlank(failedExceptionType)) { + public void recordFailure(Exception ex) { + this.hasFailed = true; + this.failure = new FailureInfo(ex.getTypeName(), ex.getMessage()); + this.retryDecision = matchesRetryOnTypes(ex.getTypeName()) && safeIsRetryable(ex); + } + + public void recordUnknownFailure() { + this.hasFailed = true; + // No exception object to classify: retry only when no type restriction was set. + this.retryDecision = retryOnExceptionTypes == null || retryOnExceptionTypes.isEmpty(); + } + + public Boolean matchesRetryOnTypes(String typeName) { + if (String.isBlank(typeName)) { return false; } if (retryOnExceptionTypes == null || retryOnExceptionTypes.isEmpty()) { @@ -104,8 +137,8 @@ public abstract class QueueableJob implements Queueable, Comparable { } for (String allowedType : retryOnExceptionTypes) { if ( - failedExceptionType.equalsIgnoreCase(allowedType) || - failedExceptionType.endsWithIgnoreCase('.' + allowedType) + typeName.equalsIgnoreCase(allowedType) || + typeName.endsWithIgnoreCase('.' + allowedType) ) { return true; } @@ -113,15 +146,35 @@ public abstract class QueueableJob implements Queueable, Comparable { return false; } + public Boolean safeIsRetryable(Exception ex) { + try { + return isRetryable(ex); + } catch (Exception overrideEx) { + appendRetryHistoryLine( + 'isRetryable() override threw ' + + overrideEx.getTypeName() + + ': ' + + overrideEx.getMessage() + + ' — not retried' + ); + return false; + } + } + public void recordRetryHistory(Integer attemptNumber, Integer delayMinutes) { - String line = + String cause = failure != null + ? failure.type + ' - ' + failure.message + : 'exception unavailable'; + appendRetryHistoryLine( 'Attempt ' + - attemptNumber + - ': ' + - failedExceptionType + - ' - ' + - failedExceptionMessage + - (delayMinutes != null ? ' (retry in ' + delayMinutes + 'm)' : ' (no further retry)'); + attemptNumber + + ': ' + + cause + + (delayMinutes != null ? ' (retry in ' + delayMinutes + 'm)' : ' (no further retry)') + ); + } + + private void appendRetryHistoryLine(String line) { retryHistory = String.isBlank(retryHistory) ? line : retryHistory + '\n' + line; } @@ -143,20 +196,6 @@ public abstract class QueueableJob implements Queueable, Comparable { return this.clone(); } - public virtual QueueableJob cloneForDeepCopy() { - String fullName = this.className; - Type resolvedType = Type.forName(fullName); - - if (resolvedType == null && fullName.contains('.')) { - resolvedType = Type.forName( - fullName.substringBefore('.'), - fullName.substringAfter('.') - ); - } - - return (QueueableJob) JSON.deserialize(JSON.serialize(this), resolvedType); - } - private String getFullClassName(Object job) { String result; try { @@ -169,6 +208,16 @@ public abstract class QueueableJob implements Queueable, Comparable { return result; } + public class FailureInfo { + public String type; + public String message; + + public FailureInfo(String type, String message) { + this.type = type; + this.message = message; + } + } + public abstract class AllowsCallouts extends QueueableJob implements Database.AllowsCallouts { } diff --git a/website/api/queueable.md b/website/api/queueable.md index c837760..90fab9e 100644 --- a/website/api/queueable.md +++ b/website/api/queueable.md @@ -84,6 +84,12 @@ The following are methods for using Async with Queueable jobs: - [`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) +- [`resetForRetry()`](#resetforretry) + ### INIT #### queueable @@ -264,10 +270,21 @@ a higher value (whether passed to `retry(...)` or configured via `QueueableJobSetting__mdt`) throws an exception. On each failed attempt the framework re-enqueues a fresh clone of the job with -an incremented attempt counter. By default **every** exception is retried. As a -best practice, narrow retries to the failures you know are worth re-running with -[`retryOn(...)`](#retryon). Retry composes with `continueOnJobExecuteFail`: once -retries are exhausted the chain behaves exactly as it would for a non-retry job. +an incremented attempt counter. 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` @@ -321,8 +338,16 @@ Async.queueable(new MyQueueableJob()) #### retryOn -Restricts retry to the listed exception types. Call multiple times to add more. -When omitted, retry applies to any exception. +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** @@ -698,3 +723,79 @@ void skipJob(String customJobId); ```apex Async.skipJob(notificationsResult.customJobId); ``` + +### 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')); + } +} +``` + +#### resetForRetry + +Override to reset transient state before a retry runs. The framework re-enqueues +a **clone** of the failed job; a shallow clone copies object members by +reference, so anything that accumulated state 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. `resetForRetry()` runs on the fresh retry +clone, after the framework has reset its own bookkeeping — recreate or clear +your transient members here. Default is a no-op. + +**Signature** + +```apex +public virtual void resetForRetry(); +``` + +**Example** + +```apex +public class SyncContactsJob extends QueueableJob { + private MyUnitOfWork uow = new MyUnitOfWork(); + + public override void work() { + /* registers into uow, then commits */ + } + + public override void resetForRetry() { + this.uow = new MyUnitOfWork(); // fresh, empty — drop the failed run's registrations + } +} +```