diff --git a/force-app/main/default/classes/Async.cls b/force-app/main/default/classes/Async.cls index 38bd8d3..4b65ddc 100644 --- a/force-app/main/default/classes/Async.cls +++ b/force-app/main/default/classes/Async.cls @@ -15,8 +15,8 @@ public inherited sharing class Async { return QueueableManager.get().getQueueableJobContext(); } - public static Id getQueueableChainBatchId() { - return QueueableManager.get().getQueueableChainBatchId(); + public static Id getQueueableChainSchedulableId() { + return QueueableManager.get().getQueueableChainSchedulableId(); } public static QueueableChainState getCurrentQueueableChainState() { diff --git a/force-app/main/default/classes/Async.cls-meta.xml b/force-app/main/default/classes/Async.cls-meta.xml index 1e7de94..82775b9 100644 --- a/force-app/main/default/classes/Async.cls-meta.xml +++ b/force-app/main/default/classes/Async.cls-meta.xml @@ -1,5 +1,5 @@ - 64.0 + 65.0 Active diff --git a/force-app/main/default/classes/AsyncTest.cls b/force-app/main/default/classes/AsyncTest.cls index 952a4b3..22a1b9e 100644 --- a/force-app/main/default/classes/AsyncTest.cls +++ b/force-app/main/default/classes/AsyncTest.cls @@ -19,7 +19,8 @@ private class AsyncTest implements Database.Batchable { SuccessfulQueueableTest q = new SuccessfulQueueableTest(); Assert.areEqual(0, [SELECT COUNT() FROM Account]); - Id initialQueuableChainBatchJobId; + Id initialQueuableChainSchedulableId; + List cronTriggers; Test.startTest(); for (Integer idx = 0; idx < 60; idx++) { @@ -32,13 +33,18 @@ private class AsyncTest implements Database.Batchable { ); } else { Assert.areEqual( - QueueableManager.EnqueueType.INITIAL_SCHEDULED_BATCH_JOB, + QueueableManager.EnqueueType.INITIAL_QUEUEABLE_CHAIN_SCHEDULABLE, ar.queueableChainState.enqueueType, 'Should be enqueued via batch.' ); } } - initialQueuableChainBatchJobId = Async.getQueueableChainBatchId(); + initialQueuableChainSchedulableId = Async.getQueueableChainSchedulableId(); + cronTriggers = [ + SELECT Id + FROM CronTrigger + WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%' + ]; Test.stopTest(); Assert.areEqual( @@ -46,35 +52,46 @@ private class AsyncTest implements Database.Batchable { [SELECT COUNT() FROM Account], 'Should have 50 job runs (50 queueable and 1 scheduled batch that was not executed yet).' ); - List cronTriggers = [ - SELECT Id - FROM CronTrigger - WHERE CronJobDetail.Name LIKE 'QueueableChainBatch%' - ]; Assert.areEqual( 1, cronTriggers.size(), 'Should schedule only one batchable job with the rest.' ); Assert.areEqual( - initialQueuableChainBatchJobId, + initialQueuableChainSchedulableId, cronTriggers[0].Id, - 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainBatchId()' + 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainSchedulableId()' + ); + List currentCronTriggers = [ + SELECT Id + FROM CronTrigger + WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%' + ]; + Assert.areEqual( + 0, + currentCronTriggers.size(), + 'The scheduled job should be deleted once finished.' ); } @IsTest - private static void shouldEnqueue60QueueablesWitDelaySuccessfully() { + private static void shouldEnqueue60QueueablesWithDelaySuccessfully() { SuccessfulQueueableTest q = new SuccessfulQueueableTest(); Assert.areEqual(0, [SELECT COUNT() FROM Account]); - Id initialQueuableChainBatchJobId; + Id initialQueuableChainSchedulableId; + List cronTriggers; Test.startTest(); for (Integer idx = 0; idx < 60; idx++) { Async.queueable(q).delay(1).enqueue(); } - initialQueuableChainBatchJobId = Async.getQueueableChainBatchId(); + initialQueuableChainSchedulableId = Async.getQueueableChainSchedulableId(); + cronTriggers = [ + SELECT Id + FROM CronTrigger + WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%' + ]; Test.stopTest(); Assert.areEqual( @@ -82,20 +99,25 @@ private class AsyncTest implements Database.Batchable { [SELECT COUNT() FROM Account], 'Should have 50 job runs (50 queueable and 1 scheduled batch that was not executed yet).' ); - List cronTriggers = [ - SELECT Id - FROM CronTrigger - WHERE CronJobDetail.Name LIKE 'QueueableChainBatch%' - ]; Assert.areEqual( 1, cronTriggers.size(), 'Should schedule only one batchable job with the rest.' ); Assert.areEqual( - initialQueuableChainBatchJobId, + initialQueuableChainSchedulableId, cronTriggers[0].Id, - 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainBatchId()' + 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainSchedulableId()' + ); + List currentCronTriggers = [ + SELECT Id + FROM CronTrigger + WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%' + ]; + Assert.areEqual( + 0, + currentCronTriggers.size(), + 'The scheduled job should be deleted once finished.' ); } @@ -104,30 +126,41 @@ private class AsyncTest implements Database.Batchable { FailureQueueableTest q = new FailureQueueableTest(); Assert.areEqual(0, [SELECT COUNT() FROM Account]); - Id initialQueuableChainBatchJobId; + Id initialQueuableChainSchedulableId; + List cronTriggers; Test.startTest(); for (Integer idx = 0; idx < 60; idx++) { Async.queueable(q).continueOnJobExecuteFail().rollbackOnJobExecuteFail().enqueue(); } - initialQueuableChainBatchJobId = Async.getQueueableChainBatchId(); - Test.stopTest(); - - Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Should have no Accounts created.'); - List cronTriggers = [ + initialQueuableChainSchedulableId = Async.getQueueableChainSchedulableId(); + cronTriggers = [ SELECT Id FROM CronTrigger - WHERE CronJobDetail.Name LIKE 'QueueableChainBatch%' + WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%' ]; + Test.stopTest(); + + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Should have no Accounts created.'); Assert.areEqual( 1, cronTriggers.size(), 'Should schedule only one batchable job with the rest.' ); Assert.areEqual( - initialQueuableChainBatchJobId, + initialQueuableChainSchedulableId, cronTriggers[0].Id, - 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainBatchId()' + 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainSchedulableId()' + ); + List currentCronTriggers = [ + SELECT Id + FROM CronTrigger + WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%' + ]; + Assert.areEqual( + 0, + currentCronTriggers.size(), + 'The scheduled job should be deleted once finished.' ); } @@ -136,30 +169,41 @@ private class AsyncTest implements Database.Batchable { FailureQueueableTest q = new FailureQueueableTest(); Assert.areEqual(0, [SELECT COUNT() FROM Account]); - Id initialQueuableChainBatchJobId; + Id initialQueuableChainSchedulableId; + List cronTriggers; Test.startTest(); for (Integer idx = 0; idx < 60; idx++) { Async.queueable(q).continueOnJobExecuteFail().enqueue(); } - initialQueuableChainBatchJobId = Async.getQueueableChainBatchId(); - Test.stopTest(); - - Assert.areEqual(50, [SELECT COUNT() FROM Account], 'Inserted Accounts are not rollbacked.'); - List cronTriggers = [ + initialQueuableChainSchedulableId = Async.getQueueableChainSchedulableId(); + cronTriggers = [ SELECT Id FROM CronTrigger - WHERE CronJobDetail.Name LIKE 'QueueableChainBatch%' + WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%' ]; + Test.stopTest(); + + Assert.areEqual(50, [SELECT COUNT() FROM Account], 'Inserted Accounts are not rollbacked.'); Assert.areEqual( 1, cronTriggers.size(), 'Should schedule only one batchable job with the rest.' ); Assert.areEqual( - initialQueuableChainBatchJobId, + initialQueuableChainSchedulableId, cronTriggers[0].Id, - 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainBatchId()' + 'The one scheduled batch job should have the same Id as the one returned from Async.getQueueableChainSchedulableId()' + ); + List currentCronTriggers = [ + SELECT Id + FROM CronTrigger + WHERE CronJobDetail.Name LIKE 'QueueableChainSchedulable%' + ]; + Assert.areEqual( + 0, + currentCronTriggers.size(), + 'The scheduled job should be deleted once finished.' ); } @@ -901,38 +945,670 @@ private class AsyncTest implements Database.Batchable { @IsTest static void shouldSoftCloneTheJob() { - String CHANGED_PRIMITIVE_VALUE = 'CHANGED_PRIMITIVE_VALUE'; + String changedPrimitiveValue = 'changedPrimitiveValue'; QueueableJobTest1 job1 = new QueueableJobTest1(); Async.Result result1 = Async.queueable(job1).chain(); // Complex member is passed by reference, what change the first job value - job1.complexMember.primitiveMember = CHANGED_PRIMITIVE_VALUE; + job1.complexMember.primitiveMember = changedPrimitiveValue; Async.Result result2 = Async.queueable(job1).enqueue(); QueueableJobTest1 firstEnqueuedJob = (QueueableJobTest1) result1.job; QueueableJobTest1 secondEnqueuedJob = (QueueableJobTest1) result2.job; - Assert.areEqual(CHANGED_PRIMITIVE_VALUE, firstEnqueuedJob.complexMember.primitiveMember); - Assert.areEqual(CHANGED_PRIMITIVE_VALUE, secondEnqueuedJob.complexMember.primitiveMember); + Assert.areEqual(changedPrimitiveValue, firstEnqueuedJob.complexMember.primitiveMember); + Assert.areEqual(changedPrimitiveValue, secondEnqueuedJob.complexMember.primitiveMember); } @IsTest static void shouldDeepCloneTheJob() { - String CHANGED_PRIMITIVE_VALUE = 'CHANGED_PRIMITIVE_VALUE'; + String changedPrimitiveValue = 'changedPrimitiveValue'; QueueableJobTest1 job1 = new QueueableJobTest1(); Async.Result result1 = Async.queueable(job1).deepClone().chain(); // Due to above deep clone, all values above were passed by value - job1.complexMember.primitiveMember = CHANGED_PRIMITIVE_VALUE; + job1.complexMember.primitiveMember = changedPrimitiveValue; Async.Result result2 = Async.queueable(job1).enqueue(); QueueableJobTest1 firstEnqueuedJob = (QueueableJobTest1) result1.job; QueueableJobTest1 secondEnqueuedJob = (QueueableJobTest1) result2.job; Assert.areEqual(PRIMITIVE_VALUE_INITIAL, firstEnqueuedJob.complexMember.primitiveMember); - Assert.areEqual(CHANGED_PRIMITIVE_VALUE, secondEnqueuedJob.complexMember.primitiveMember); + Assert.areEqual(changedPrimitiveValue, secondEnqueuedJob.complexMember.primitiveMember); + } + + @IsTest + static void shouldExecuteChainedQueueableJob20Times() { + Integer noOfChainedJobs = 20; + Assert.areEqual(0, [SELECT COUNT() FROM Account]); + + Test.startTest(); + Async.queueable(new ChainedQueueableJob(noOfChainedJobs)).enqueue(); + Test.stopTest(); + + Assert.areEqual(noOfChainedJobs, [SELECT COUNT() FROM Account]); + Assert.areEqual( + noOfChainedJobs, + [ + SELECT COUNT() + FROM AsyncApexJob + WHERE + Status = 'Completed' + AND JobType = 'Queueable' + AND ApexClass.Name = 'AsyncTest' + ] + ); + } + + @IsTest + static void shouldFailToSetAsyncOptionsAfterDelay() { + SuccessfulQueueableTest q = new SuccessfulQueueableTest(); + + try { + Async.queueable(q).delay(1).asyncOptions(new AsyncOptions()); + Assert.fail('Should throw exception when setting asyncOptions after delay.'); + } catch (Exception ex) { + Assert.areEqual( + QueueableManager.ERROR_MESSAGE_ASYNC_OPTIONS_AFTER_DELAY, + ex.getMessage() + ); + } + } + + @IsTest + static void shouldFailToSetDelayAfterAsyncOptions() { + SuccessfulQueueableTest q = new SuccessfulQueueableTest(); + + try { + Async.queueable(q).asyncOptions(new AsyncOptions()).delay(1); + Assert.fail('Should throw exception when setting delay after asyncOptions.'); + } catch (Exception ex) { + Assert.areEqual( + QueueableManager.ERROR_MESSAGE_DELAY_AFTER_ASYNC_OPTIONS, + ex.getMessage() + ); + } + } + + @IsTest + static void shouldSetPriorityOnJob() { + QueueableJobTest1 job = new QueueableJobTest1(); + + Async.Result result = Async.queueable(job).priority(10).chain(); + + Assert.areEqual(10, result.job.priority); + } + + @IsTest + static void shouldFailWhenBatchScopeSizeIsZeroOrLess() { + try { + Async.batchable(new AsyncTest()).scopeSize(0).execute(); + Assert.fail('Should throw exception when scope size is zero.'); + } catch (Exception ex) { + Assert.areEqual('Scope size must be greater than zero.', ex.getMessage()); + } + + try { + Async.batchable(new AsyncTest()).scopeSize(-1).execute(); + Assert.fail('Should throw exception when scope size is negative.'); + } catch (Exception ex) { + Assert.areEqual('Scope size must be greater than zero.', ex.getMessage()); + } + } + + @IsTest + static void shouldFailWhenBatchMinutesFromNowIsZeroOrLess() { + try { + Async.batchable(new AsyncTest()) + .minutesFromNow(0) + .asSchedulable() + .name(TEST_SCHEDULABLE_JOB_NAME) + .schedule(); + Assert.fail('Should throw exception when minutes from now is zero.'); + } catch (Exception ex) { + Assert.areEqual('Minutes from now must be greater than zero.', ex.getMessage()); + } + + try { + Async.batchable(new AsyncTest()) + .minutesFromNow(-5) + .asSchedulable() + .name(TEST_SCHEDULABLE_JOB_NAME) + .schedule(); + Assert.fail('Should throw exception when minutes from now is negative.'); + } catch (Exception ex) { + Assert.areEqual('Minutes from now must be greater than zero.', ex.getMessage()); + } + } + + @IsTest + static void shouldFailWhenBatchJobIsNull() { + try { + BatchableManager.get().execute(null); + Assert.fail('Should throw exception when builder is null.'); + } catch (Exception ex) { + Assert.areEqual('Batch job must be set.', ex.getMessage()); + } + + try { + BatchableBuilder builder = new BatchableBuilder(new AsyncTest()); + builder.job = null; + BatchableManager.get().execute(builder); + Assert.fail('Should throw exception when job is null.'); + } catch (Exception ex) { + Assert.areEqual('Batch job must be set.', ex.getMessage()); + } + } + + @IsTest + static void shouldExecuteBatchWithDefaultScopeSize() { + Test.startTest(); + Async.Result result = Async.batchable(new AsyncTest()).execute(); + Test.stopTest(); + + Assert.isNotNull(result.salesforceJobId); + Assert.areEqual(Async.AsyncType.BATCHABLE, result.asyncType); + } + + @IsTest + static void shouldFailWhenCronExpressionIsBlank() { + try { + new CronBuilder(''); + Assert.fail('Should throw exception when cron expression is blank.'); + } catch (Exception ex) { + Assert.areEqual('Cron expression cannot be blank.', ex.getMessage()); + } + + try { + new CronBuilder(' '); + Assert.fail('Should throw exception when cron expression is whitespace.'); + } catch (Exception ex) { + Assert.areEqual('Cron expression cannot be blank.', ex.getMessage()); + } + } + + @IsTest + static void shouldFailWhenCronExpressionHasInvalidParts() { + try { + new CronBuilder('0 0 * *'); + Assert.fail('Should throw exception when cron has less than 6 parts.'); + } catch (Exception ex) { + Assert.isTrue(ex.getMessage().contains('must have 6 or 7 parts')); + } + + try { + new CronBuilder('0 0 * * * ? * 2024 extra'); + Assert.fail('Should throw exception when cron has more than 7 parts.'); + } catch (Exception ex) { + Assert.isTrue(ex.getMessage().contains('must have 6 or 7 parts')); + } + } + + @IsTest + static void shouldParseCronExpressionWith7Parts() { + CronBuilder cron = new CronBuilder('0 30 10 * * ? 2025'); + + Assert.areEqual('0', cron.second); + Assert.areEqual('30', cron.minute); + Assert.areEqual('10', cron.hour); + Assert.areEqual('*', cron.dayOfMonth); + Assert.areEqual('*', cron.month); + Assert.areEqual('?', cron.dayOfWeek); + Assert.areEqual('2025', cron.optionalYear); + } + + @IsTest + static void shouldBuildCronExpressionWithSetters() { + CronBuilder cron = new CronBuilder() + .second('30') + .minute('15') + .hour('8') + .dayOfMonth('1') + .month('6') + .dayOfWeek('MON') + .optionalYear('2025'); + + String expression = cron.getCronExpression(); + Assert.areEqual('30 15 8 1 6 MON 2025', expression); + } + + @IsTest + static void shouldBuildCronExpressionWithDefaults() { + CronBuilder cron = new CronBuilder(); + String expression = cron.getCronExpression(); + + Assert.areEqual('0 0 * * * ? *', expression); + } + + @IsTest + static void shouldFailWhenEveryXMinutesIsZeroOrNegative() { + try { + new CronBuilder().buildForEveryXMinutes(0); + Assert.fail('Should throw exception when everyXMinutes is zero.'); + } catch (Exception ex) { + Assert.areEqual('Every X minutes must be greater than zero.', ex.getMessage()); + } + + try { + new CronBuilder().buildForEveryXMinutes(-1); + Assert.fail('Should throw exception when everyXMinutes is negative.'); + } catch (Exception ex) { + Assert.areEqual('Every X minutes must be greater than zero.', ex.getMessage()); + } + } + + @IsTest + static void shouldFailWhenEveryXHoursIsInvalid() { + try { + new CronBuilder().everyXHours(0, 30); + Assert.fail('Should throw exception when everyXHours is zero.'); + } catch (Exception ex) { + Assert.areEqual('Every X hours must be greater than zero.', ex.getMessage()); + } + + try { + new CronBuilder().everyXHours(13, 30); + Assert.fail('Should throw exception when everyXHours is greater than 12.'); + } catch (Exception ex) { + Assert.areEqual('Every X hours must be 12 or less.', ex.getMessage()); + } + } + + @IsTest + static void shouldFailWhenEveryXDaysIsInvalid() { + try { + new CronBuilder().everyXDays(0, 10, 30); + Assert.fail('Should throw exception when everyXDays is zero.'); + } catch (Exception ex) { + Assert.areEqual('Every X days must be greater than zero.', ex.getMessage()); + } + + try { + new CronBuilder().everyXDays(16, 10, 30); + Assert.fail('Should throw exception when everyXDays is greater than 15.'); + } catch (Exception ex) { + Assert.areEqual('Every X days must be 15 or less.', ex.getMessage()); + } + } + + @IsTest + static void shouldFailWhenEveryXMonthsIsInvalid() { + try { + new CronBuilder().everyXMonths(0, 1, 10, 30); + Assert.fail('Should throw exception when everyXMonths is zero.'); + } catch (Exception ex) { + Assert.areEqual('Every X months must be greater than zero.', ex.getMessage()); + } + + try { + new CronBuilder().everyXMonths(7, 1, 10, 30); + Assert.fail('Should throw exception when everyXMonths is greater than 6.'); + } catch (Exception ex) { + Assert.areEqual('Every X months must be 6 or less.', ex.getMessage()); + } + } + + @IsTest + static void shouldBuildEveryHourCron() { + CronBuilder cron = new CronBuilder().everyHour(45); + + Assert.areEqual('45', cron.minute); + String expression = cron.getCronExpression(); + Assert.isTrue(expression.contains('45')); + } + + @IsTest + static void shouldBuildEveryDayCron() { + CronBuilder cron = new CronBuilder().everyDay(14, 30); + + Assert.areEqual('14', cron.hour); + Assert.areEqual('30', cron.minute); + } + + @IsTest + static void shouldBuildEveryMonthCron() { + CronBuilder cron = new CronBuilder().everyMonth(15, 9, 0); + + Assert.areEqual('15', cron.dayOfMonth); + Assert.areEqual('9', cron.hour); + Assert.areEqual('0', cron.minute); + } + + @IsTest + static void shouldFailWhenQueueableChainFinalizerChainIsNull() { + try { + new QueueableChainFinalizer(null); + Assert.fail('Should throw exception when chain is null.'); + } catch (Exception ex) { + Assert.areEqual('QueueableChain cannot be null', ex.getMessage()); + } + } + + @IsTest + static void shouldFailWhenQueueableChainSchedulableChainIsNull() { + try { + new QueueableChainSchedulable(null); + Assert.fail('Should throw exception when chain is null.'); + } catch (Exception ex) { + Assert.areEqual('QueueableChain cannot be null', ex.getMessage()); + } + } + + @IsTest + static void shouldGetCurrentQueueableChainState() { + SuccessfulQueueableTest q = new SuccessfulQueueableTest(); + + Async.queueable(q).chain(); + Async.QueueableChainState state = Async.getCurrentQueueableChainState(); + + Assert.isNotNull(state); + Assert.isNotNull(state.jobs); + Assert.areEqual(1, state.jobs.size()); + } + + @IsTest + static void shouldGetQueueableJobContext() { + Async.QueueableJobContext ctx = Async.getQueueableJobContext(); + + Assert.isNotNull(ctx); + Assert.isNull(ctx.currentJob); + } + + @IsTest + @SuppressWarnings('PMD.ApexUnitTestClassShouldHaveAsserts') + static void shouldRemoveSchedulableJobWhenIdIsNull() { + QueueableChainSchedulable.removeInitialQueuableChainSchedulableIfExists(null); + } + + @IsTest + @SuppressWarnings('PMD.ApexUnitTestClassShouldHaveAsserts,PMD.AvoidHardcodingId') + static void shouldHandleRemoveSchedulableJobWithInvalidId() { + QueueableChainSchedulable.removeInitialQueuableChainSchedulableIfExists( + '001000000000000AAA' + ); + } + + @IsTest + static void shouldChainMultipleJobsWithBuilder() { + QueueableJobTest1 job1 = new QueueableJobTest1(); + QueueableJobTest2 job2 = new QueueableJobTest2(); + + Async.queueable(job1).chain(job2).chain(); + + Async.QueueableChainState state = Async.getCurrentQueueableChainState(); + Assert.areEqual(2, state.jobs.size()); + } + + @IsTest + static void shouldConvertQueueableToSchedulable() { + SuccessfulQueueableTest q = new SuccessfulQueueableTest(); + + Test.startTest(); + Async.queueable(q) + .asSchedulable() + .name(TEST_SCHEDULABLE_JOB_NAME) + .cronExpression(new CronBuilder().everyDay(10, 30)) + .schedule(); + Test.stopTest(); + + List triggers = [ + SELECT Id + FROM CronTrigger + WHERE CronJobDetail.Name = :TEST_SCHEDULABLE_JOB_NAME + ]; + Assert.areEqual(1, triggers.size()); + } + + @IsTest + static void shouldHandleQueueableChainStateWithNullJobs() { + Async.QueueableChainState state = new Async.QueueableChainState(); + + state.setNextSalesforceJobIdFromFirstJob(); + Assert.isNull(state.nextSalesforceJobId); + + state.setNextCustomJobIdFromFirstJob(); + Assert.isNull(state.nextCustomJobId); + } + + @IsTest + static void shouldHandleQueueableChainStateWithEmptyJobs() { + Async.QueueableChainState state = new Async.QueueableChainState(); + state.setJobs(new List()); + + state.setNextSalesforceJobIdFromFirstJob(); + Assert.isNull(state.nextSalesforceJobId); + + state.setNextCustomJobIdFromFirstJob(); + Assert.isNull(state.nextCustomJobId); + } + + @IsTest + static void shouldSetEnqueueTypeOnChainState() { + Async.QueueableChainState state = new Async.QueueableChainState(); + + state.setEnqueueType(QueueableManager.EnqueueType.NEW_CHAIN); + Assert.areEqual(QueueableManager.EnqueueType.NEW_CHAIN, state.enqueueType); + + state.setEnqueueType(QueueableManager.EnqueueType.EXISTING_CHAIN); + Assert.areEqual(QueueableManager.EnqueueType.EXISTING_CHAIN, state.enqueueType); + } + + @IsTest + static void shouldCompareJobsWithBothFinalizersAndSamePriority() { + QueueableJobTest1 job1 = new QueueableJobTest1(); + job1.parentCustomJobId = 'parent1'; + job1.priority = 10; + + QueueableJobTest2 job2 = new QueueableJobTest2(); + job2.parentCustomJobId = 'parent2'; + job2.priority = 10; + + Integer result = job1.compareTo(job2); + Assert.areEqual(0, result); + } + + @IsTest + static void shouldCompareJobsWithBothNullPriorities() { + QueueableJobTest1 job1 = new QueueableJobTest1(); + job1.priority = null; + + QueueableJobTest2 job2 = new QueueableJobTest2(); + job2.priority = null; + + Integer result = job1.compareTo(job2); + Assert.areEqual(0, result); + } + + @IsTest + static void shouldCompareJobsWithOnePriorityNull() { + QueueableJobTest1 job1 = new QueueableJobTest1(); + job1.priority = 10; + + QueueableJobTest2 job2 = new QueueableJobTest2(); + job2.priority = null; + + Integer result = job1.compareTo(job2); + Assert.areEqual(-1, result); + + result = job2.compareTo(job1); + Assert.areEqual(1, result); + } + + @IsTest + static void shouldUseSchedulableBuilderWithStringCronExpression() { + Test.startTest(); + Async.schedulable(new SchedulableTest()) + .name(TEST_SCHEDULABLE_JOB_NAME) + .cronExpression('0 0 12 * * ?') + .schedule(); + Test.stopTest(); + + List triggers = [ + SELECT CronExpression + FROM CronTrigger + WHERE CronJobDetail.Name = :TEST_SCHEDULABLE_JOB_NAME + ]; + Assert.areEqual(1, triggers.size()); + Assert.areEqual('0 0 12 * * ? *', triggers[0].CronExpression); + } + + @IsTest + static void shouldBuildEveryXMinutesCrons() { + List crons = new CronBuilder().buildForEveryXMinutes(15); + + Assert.areEqual(4, crons.size()); + Assert.areEqual('0', crons[0].minute); + Assert.areEqual('15', crons[1].minute); + Assert.areEqual('30', crons[2].minute); + Assert.areEqual('45', crons[3].minute); + } + + @IsTest + static void shouldScheduleWithBoundaryEveryXMinutes() { + List crons30 = new CronBuilder().buildForEveryXMinutes(30); + Assert.areEqual(2, crons30.size()); + + List crons1 = new CronBuilder().buildForEveryXMinutes(1); + Assert.areEqual(60, crons1.size()); + } + + @IsTest + static void shouldTestFinalizerWithSuccessViaEnqueue() { + AsyncMock.whenFinalizer('error-handler').thenReturn(ParentJobResult.SUCCESS); + + Test.startTest(); + Async.queueable(new ParentJobWithFinalizer('error-handler')).enqueue(); + Test.stopTest(); + + Assert.areEqual(0, [SELECT COUNT() FROM Account]); + } + + @IsTest + static void shouldTestFinalizerWithExceptionViaEnqueue() { + AsyncMock.whenFinalizer('error-handler').thenThrow(new DmlException('Parent job failed')); + + Test.startTest(); + Async.queueable(new ParentJobWithFinalizer('error-handler')).enqueue(); + Test.stopTest(); + + Account errorLog = [SELECT Name, Description FROM Account LIMIT 1]; + Assert.areEqual('Error Log', errorLog.Name); + Assert.areEqual('Parent job failed', errorLog.Description); + } + + @IsTest + static void shouldTestFinalizerDirectlyWithMockContext() { + ErrorHandlerFinalizer finalizer = new ErrorHandlerFinalizer(); + finalizer.finalizerCtx = new AsyncMock.MockFinalizerContext() + .setResult(ParentJobResult.UNHANDLED_EXCEPTION) + .setException(new DmlException('Direct test error')); + + finalizer.work(); + + Account errorLog = [SELECT Name, Description FROM Account LIMIT 1]; + Assert.areEqual('Error Log', errorLog.Name); + Assert.areEqual('Direct test error', errorLog.Description); + } + + @IsTest + static void shouldTestQueueableWithMockContext() { + AsyncMock.whenQueueable('account-creator').thenReturn(new AsyncMock.MockQueueableContext()); + + Test.startTest(); + Async.queueable(new AccountCreatorJob('Test Account')).mockId('account-creator').enqueue(); + Test.stopTest(); + + Account acc = [SELECT Name, Description FROM Account LIMIT 1]; + Assert.areEqual('Test Account', acc.Name); + Assert.isNotNull(acc.Description); + } + + @IsTest + static void shouldTestQueueableDirectlyWithMockContext() { + AccountCreatorJob job = new AccountCreatorJob('Direct Test'); + job.queueableCtx = new AsyncMock.MockQueueableContext(); + + job.work(); + + Account acc = [SELECT Name, Description FROM Account LIMIT 1]; + Assert.areEqual('Direct Test', acc.Name); + Assert.isNotNull(acc.Description); + } + + @IsTest + static void shouldTestMultipleFinalizerInvocations() { + AsyncMock.whenFinalizer('multi-test') + .thenReturn(ParentJobResult.SUCCESS) + .thenThrow(new DmlException('Second call failed')) + .thenReturn(ParentJobResult.SUCCESS); + + Test.startTest(); + Async.queueable(new ParentJobWithFinalizer('multi-test')).enqueue(); + Async.queueable(new ParentJobWithFinalizer('multi-test')).enqueue(); + Async.queueable(new ParentJobWithFinalizer('multi-test')).enqueue(); + Test.stopTest(); + + Assert.areEqual(1, [SELECT COUNT() FROM Account]); + Assert.areEqual( + 'Second call failed', + [SELECT Description FROM Account LIMIT 1].Description + ); + } + + @IsTest + static void shouldTestDefaultFinalizerMock() { + AsyncMock.whenFinalizerDefault().thenReturn(ParentJobResult.SUCCESS); + + Test.startTest(); + Async.queueable(new ParentJobWithFinalizer('job-1')).enqueue(); + Async.queueable(new ParentJobWithFinalizer('job-2')).enqueue(); + Test.stopTest(); + + Assert.areEqual(0, [SELECT COUNT() FROM Account]); + } + + @IsTest + static void shouldResetMocks() { + AsyncMock.whenFinalizer('test').thenReturn(ParentJobResult.SUCCESS); + AsyncMock.whenQueueable('test').thenReturn(new AsyncMock.MockQueueableContext()); + AsyncMock.whenFinalizerDefault().thenReturn(ParentJobResult.SUCCESS); + AsyncMock.whenQueueableDefault().thenReturn(new AsyncMock.MockQueueableContext()); + + AsyncMock.reset(); + + Assert.isNull(AsyncMock.getFinalizerContext('test')); + Assert.isNull(AsyncMock.getQueueableContext('test')); + Assert.isNull(AsyncMock.getFinalizerContext('unknown')); + Assert.isNull(AsyncMock.getQueueableContext('unknown')); + } + + @IsTest + static void shouldReturnNullWhenAllMocksConsumed() { + AsyncMock.whenFinalizer('test') + .thenReturn(ParentJobResult.SUCCESS) + .thenThrow(new DmlException('Error')); + + FinalizerContext ctx1 = AsyncMock.getFinalizerContext('test'); + FinalizerContext ctx2 = AsyncMock.getFinalizerContext('test'); + FinalizerContext ctx3 = AsyncMock.getFinalizerContext('test'); + + Assert.areEqual(ParentJobResult.SUCCESS, ctx1.getResult()); + Assert.areEqual(ParentJobResult.UNHANDLED_EXCEPTION, ctx2.getResult()); + Assert.isNull(ctx3); + } + + @IsTest + static void shouldUseDefaultMockWhenSpecificMocksConsumed() { + AsyncMock.whenFinalizerDefault().thenReturn(ParentJobResult.SUCCESS); + AsyncMock.whenFinalizer('test').thenThrow(new DmlException('Error')); + + FinalizerContext ctx1 = AsyncMock.getFinalizerContext('test'); + FinalizerContext ctx2 = AsyncMock.getFinalizerContext('test'); + + Assert.areEqual(ParentJobResult.UNHANDLED_EXCEPTION, ctx1.getResult()); + Assert.areEqual(ParentJobResult.SUCCESS, ctx2.getResult()); } private static String getClassNameWithNamespaceDotPrefix(String className) { @@ -950,9 +1626,16 @@ private class AsyncTest implements Database.Batchable { } public void execute(Database.BatchableContext ctx, List scope) { + for (Account acc : scope) { + acc.Description = 'Processed by: ' + ctx.getJobId(); + } + if (!scope.isEmpty() && scope[0].Id != null) { + update scope; + } } public void finish(Database.BatchableContext bc) { + insert new Account(Name = 'Batch Complete', Description = 'Job: ' + bc.getJobId()); } private class SuccessfulQueueableTest extends QueueableJob { @@ -986,6 +1669,23 @@ private class AsyncTest implements Database.Batchable { } } + private class ChainedQueueableJob extends QueueableJob { + private Integer chainDepthLimit = 1; + private Integer currentChainDepth = 0; + + public ChainedQueueableJob(Integer chainDepthLimit) { + this.chainDepthLimit = chainDepthLimit; + } + + public override void work() { + currentChainDepth++; + insert new Account(Name = Async.getQueueableJobContext()?.currentJob?.uniqueName); + if (currentChainDepth < chainDepthLimit) { + Async.queueable(this).enqueue(); + } + } + } + public class QueueableJobTest1 extends QueueableJob { public QueueableJobTest2 complexMember = new QueueableJobTest2(); public override void work() { @@ -1035,4 +1735,42 @@ private class AsyncTest implements Database.Batchable { private class CustomException extends Exception { } + + public class ParentJobWithFinalizer extends QueueableJob { + private String mockId; + + public ParentJobWithFinalizer(String mockId) { + this.mockId = mockId; + } + + public override void work() { + Async.queueable(new ErrorHandlerFinalizer()).mockId(mockId).attachFinalizer(); + } + } + + public class ErrorHandlerFinalizer extends QueueableJob.Finalizer { + public override void work() { + FinalizerContext ctx = this.finalizerCtx; + if (ctx?.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) { + insert new Account( + Name = 'Error Log', + Description = ctx.getException()?.getMessage() + ); + } + } + } + + public class AccountCreatorJob extends QueueableJob { + private String accountName; + + public AccountCreatorJob(String accountName) { + this.accountName = accountName; + } + + public override void work() { + Id jobId = this.queueableCtx?.getJobId(); + insert new Account(Name = accountName, Description = 'Job: ' + jobId); + } + } + } diff --git a/force-app/main/default/classes/AsyncTest.cls-meta.xml b/force-app/main/default/classes/AsyncTest.cls-meta.xml index 1e7de94..82775b9 100644 --- a/force-app/main/default/classes/AsyncTest.cls-meta.xml +++ b/force-app/main/default/classes/AsyncTest.cls-meta.xml @@ -1,5 +1,5 @@ - 64.0 + 65.0 Active diff --git a/force-app/main/default/classes/batch/BatchableBuilder.cls-meta.xml b/force-app/main/default/classes/batch/BatchableBuilder.cls-meta.xml index 1e7de94..82775b9 100644 --- a/force-app/main/default/classes/batch/BatchableBuilder.cls-meta.xml +++ b/force-app/main/default/classes/batch/BatchableBuilder.cls-meta.xml @@ -1,5 +1,5 @@ - 64.0 + 65.0 Active diff --git a/force-app/main/default/classes/batch/BatchableManager.cls-meta.xml b/force-app/main/default/classes/batch/BatchableManager.cls-meta.xml index 1e7de94..82775b9 100644 --- a/force-app/main/default/classes/batch/BatchableManager.cls-meta.xml +++ b/force-app/main/default/classes/batch/BatchableManager.cls-meta.xml @@ -1,5 +1,5 @@ - 64.0 + 65.0 Active diff --git a/force-app/main/default/classes/mocks/AsyncMock.cls b/force-app/main/default/classes/mocks/AsyncMock.cls new file mode 100644 index 0000000..03a7af8 --- /dev/null +++ b/force-app/main/default/classes/mocks/AsyncMock.cls @@ -0,0 +1,157 @@ +@IsTest +public class AsyncMock { + private static Map finalizerSetups = new Map(); + private static Map queueableSetups = new Map(); + private static FinalizerMockSetup defaultFinalizerSetup; + private static QueueableMockSetup defaultQueueableSetup; + + public static FinalizerMockSetup whenFinalizer(String mockId) { + FinalizerMockSetup setup = new FinalizerMockSetup(); + finalizerSetups.put(mockId, setup); + return setup; + } + + public static FinalizerMockSetup whenFinalizerDefault() { + defaultFinalizerSetup = new FinalizerMockSetup(); + return defaultFinalizerSetup; + } + + public static QueueableMockSetup whenQueueable(String mockId) { + QueueableMockSetup setup = new QueueableMockSetup(); + queueableSetups.put(mockId, setup); + return setup; + } + + public static QueueableMockSetup whenQueueableDefault() { + defaultQueueableSetup = new QueueableMockSetup(); + return defaultQueueableSetup; + } + + public static FinalizerContext getFinalizerContext(String mockId) { + FinalizerMockSetup setup = finalizerSetups.get(mockId); + FinalizerContext ctx = setup?.getNext(); + if (ctx == null && defaultFinalizerSetup != null) { + ctx = defaultFinalizerSetup.getNext(); + } + return ctx; + } + + public static QueueableContext getQueueableContext(String mockId) { + QueueableMockSetup setup = queueableSetups.get(mockId); + QueueableContext ctx = setup?.getNext(); + if (ctx == null && defaultQueueableSetup != null) { + ctx = defaultQueueableSetup.getNext(); + } + return ctx; + } + + public static Boolean hasQueueableMock(String mockId) { + return queueableSetups.containsKey(mockId) || defaultQueueableSetup != null; + } + + public static Boolean hasFinalizerMock(String mockId) { + return finalizerSetups.containsKey(mockId) || defaultFinalizerSetup != null; + } + + public static void reset() { + finalizerSetups.clear(); + queueableSetups.clear(); + defaultFinalizerSetup = null; + defaultQueueableSetup = null; + } + + public class FinalizerMockSetup { + private List contexts = new List(); + + public FinalizerMockSetup thenReturn(FinalizerContext ctx) { + contexts.add(ctx); + return this; + } + + public FinalizerMockSetup thenReturn(ParentJobResult result) { + return thenReturn(new MockFinalizerContext().setResult(result)); + } + + public FinalizerMockSetup thenThrow(Exception ex) { + return thenReturn(new MockFinalizerContext().setException(ex)); + } + + public FinalizerContext getNext() { + if (contexts.isEmpty()) { + return null; + } + return contexts.remove(0); + } + } + + public class QueueableMockSetup { + private List contexts = new List(); + + public QueueableMockSetup thenReturn(QueueableContext ctx) { + contexts.add(ctx); + return this; + } + + public QueueableMockSetup thenReturn(Id jobId) { + return thenReturn(new MockQueueableContext().setJobId(jobId)); + } + + public QueueableContext getNext() { + if (contexts.isEmpty()) { + return null; + } + return contexts.remove(0); + } + } + + public class MockFinalizerContext implements System.FinalizerContext { + private ParentJobResult result = ParentJobResult.SUCCESS; + private Exception ex; + private Id jobId; + + public MockFinalizerContext setResult(ParentJobResult result) { + this.result = result; + return this; + } + + public MockFinalizerContext setException(Exception ex) { + this.ex = ex; + this.result = ParentJobResult.UNHANDLED_EXCEPTION; + return this; + } + + public MockFinalizerContext setJobId(Id jobId) { + this.jobId = jobId; + return this; + } + + public ParentJobResult getResult() { + return result; + } + + public Exception getException() { + return ex; + } + + public Id getAsyncApexJobId() { + return jobId; + } + + public String getRequestId() { + return 'mock-request-id'; + } + } + + public class MockQueueableContext implements System.QueueableContext { + private Id jobId; + + public MockQueueableContext setJobId(Id jobId) { + this.jobId = jobId; + return this; + } + + public Id getJobId() { + return jobId; + } + } +} diff --git a/force-app/main/default/classes/queue/QueueableChainBatch.cls-meta.xml b/force-app/main/default/classes/mocks/AsyncMock.cls-meta.xml similarity index 80% rename from force-app/main/default/classes/queue/QueueableChainBatch.cls-meta.xml rename to force-app/main/default/classes/mocks/AsyncMock.cls-meta.xml index 1e7de94..82775b9 100644 --- a/force-app/main/default/classes/queue/QueueableChainBatch.cls-meta.xml +++ b/force-app/main/default/classes/mocks/AsyncMock.cls-meta.xml @@ -1,5 +1,5 @@ - 64.0 + 65.0 Active diff --git a/force-app/main/default/classes/queue/QueueableBuilder.cls b/force-app/main/default/classes/queue/QueueableBuilder.cls index dba4d20..4766b93 100644 --- a/force-app/main/default/classes/queue/QueueableBuilder.cls +++ b/force-app/main/default/classes/queue/QueueableBuilder.cls @@ -52,6 +52,11 @@ public inherited sharing class QueueableBuilder { return this; } + public QueueableBuilder mockId(String mockId) { + job.mockId = mockId; + return this; + } + public QueueableBuilder chain(QueueableJob job) { chain(); this.job = job; diff --git a/force-app/main/default/classes/queue/QueueableBuilder.cls-meta.xml b/force-app/main/default/classes/queue/QueueableBuilder.cls-meta.xml index 1e7de94..82775b9 100644 --- a/force-app/main/default/classes/queue/QueueableBuilder.cls-meta.xml +++ b/force-app/main/default/classes/queue/QueueableBuilder.cls-meta.xml @@ -1,5 +1,5 @@ - 64.0 + 65.0 Active diff --git a/force-app/main/default/classes/queue/QueueableChain.cls b/force-app/main/default/classes/queue/QueueableChain.cls index 8525313..9c8abb5 100644 --- a/force-app/main/default/classes/queue/QueueableChain.cls +++ b/force-app/main/default/classes/queue/QueueableChain.cls @@ -7,7 +7,7 @@ public inherited sharing class QueueableChain { @TestVisible private List jobs = new List(); @TestVisible - private Id initialQueuableChainBatchJobId; + private Id initialQueuableChainSchedulableId; private Boolean isChainedContext = false; private Integer chainCounter = 0; private QueueableJob currentJob; @@ -46,6 +46,9 @@ public inherited sharing class QueueableChain { currentJob.queueableCtx = ctx; currentJob.salesforceJobId = ctx.getJobId(); currentJob.setChainCounterToName(++chainCounter); + + injectQueueableMockIfNeeded(currentJob); + Savepoint sp = currentJob.rollbackOnJobExecuteFail ? Database.setSavepoint() : null; try { // Debug statements to determine the Job Id and Name @@ -115,8 +118,8 @@ public inherited sharing class QueueableChain { return jobs; } - public void executeOrReplaceInitialQueueableChainBatchJob() { - QueueableChainBatch.removeInitialQueuableChainBatchJobIfExists(initialQueuableChainBatchJobId); + public void executeOrReplaceInitialQueueableChainSchedulableJob() { + QueueableChainSchedulable.removeInitialQueuableChainSchedulableIfExists(initialQueuableChainSchedulableId); Datetime nextRunTime = Datetime.now().addMinutes(1); String hour = String.valueOf(nextRunTime.hour()); @@ -124,7 +127,7 @@ public inherited sharing class QueueableChain { String ss = String.valueOf(nextRunTime.second()); String nextFireTime = ss + ' ' + min + ' ' + hour + ' * * ?'; - initialQueuableChainBatchJobId = System.schedule('QueueableChainBatch/' + String.valueOf(Datetime.now()) + '/' + UUID.randomUUID().toString(), nextFireTime, new QueueableChainBatch(this)); + initialQueuableChainSchedulableId = System.schedule('QueueableChainSchedulable/' + String.valueOf(Datetime.now()) + '/' + UUID.randomUUID().toString(), nextFireTime, new QueueableChainSchedulable(this)); } public QueueableJob getCurrentJob() { @@ -135,8 +138,8 @@ public inherited sharing class QueueableChain { return isChainedContext; } - public Id getQueueableChainBatchId() { - return initialQueuableChainBatchJobId; + public Id getQueueableChainSchedulableId() { + return initialQueuableChainSchedulableId; } @TestVisible @@ -207,8 +210,22 @@ public inherited sharing class QueueableChain { private void setFinalizerContextToAllFinalizersForPreviousJob(QueueableJob previousJob, FinalizerContext ctx) { for (QueueableJob job : jobs) { if (!job.isProcessed && job.parentCustomJobId == previousJob.customJobId && job.finalizerCtx == null) { - job.finalizerCtx = ctx; + injectFinalizerMockOrDefault(job, ctx); } } } + + private void injectQueueableMockIfNeeded(QueueableJob job) { + if (Test.isRunningTest() && AsyncMock.hasQueueableMock(job.mockId)) { + job.queueableCtx = AsyncMock.getQueueableContext(job.mockId); + } + } + + private void injectFinalizerMockOrDefault(QueueableJob job, FinalizerContext defaultCtx) { + if (Test.isRunningTest() && AsyncMock.hasFinalizerMock(job.mockId)) { + job.finalizerCtx = AsyncMock.getFinalizerContext(job.mockId); + return; + } + job.finalizerCtx = defaultCtx; + } } diff --git a/force-app/main/default/classes/queue/QueueableChain.cls-meta.xml b/force-app/main/default/classes/queue/QueueableChain.cls-meta.xml index 1e7de94..82775b9 100644 --- a/force-app/main/default/classes/queue/QueueableChain.cls-meta.xml +++ b/force-app/main/default/classes/queue/QueueableChain.cls-meta.xml @@ -1,5 +1,5 @@ - 64.0 + 65.0 Active diff --git a/force-app/main/default/classes/queue/QueueableChainBatch.cls b/force-app/main/default/classes/queue/QueueableChainBatch.cls deleted file mode 100644 index f991355..0000000 --- a/force-app/main/default/classes/queue/QueueableChainBatch.cls +++ /dev/null @@ -1,46 +0,0 @@ -/** - * PMD False Positives: - * - EmptyCatchBlock: Catch is just for additional safety when creating initial job, and later removing it - **/ -@SuppressWarnings('PMD.EmptyCatchBlock') -public inherited sharing class QueueableChainBatch implements Schedulable, Database.Batchable { - private QueueableChain chain; - private String cronTriggerId; - - public QueueableChainBatch(QueueableChain chain) { - if (chain == null) { - throw new IllegalArgumentException('QueueableChain cannot be null'); - } - this.chain = chain; - } - - public void execute(SchedulableContext ctx) { - this.cronTriggerId = ctx.getTriggerId(); - Database.executeBatch(this, 1); - } - - public void execute(Database.BatchableContext bc, List scope) { - chain.enqueueNextJobIfAny(); - } - - public static void removeInitialQueuableChainBatchJobIfExists(Id jobId) { - if (jobId == null) { - return; - } - - try { - System.abortJob(jobId); - } catch (Exception e) { - // No action if not exists - } - } - - public Iterable start(Database.BatchableContext bc) { - // This is just a placeholder to start the batch. - return new List{ new Account() }; - } - - public void finish(Database.BatchableContext bc) { - System.abortJob(this.cronTriggerId); - } -} diff --git a/force-app/main/default/classes/queue/QueueableChainFinalizer.cls-meta.xml b/force-app/main/default/classes/queue/QueueableChainFinalizer.cls-meta.xml index 1e7de94..82775b9 100644 --- a/force-app/main/default/classes/queue/QueueableChainFinalizer.cls-meta.xml +++ b/force-app/main/default/classes/queue/QueueableChainFinalizer.cls-meta.xml @@ -1,5 +1,5 @@ - 64.0 + 65.0 Active diff --git a/force-app/main/default/classes/queue/QueueableChainSchedulable.cls b/force-app/main/default/classes/queue/QueueableChainSchedulable.cls new file mode 100644 index 0000000..f8fed3e --- /dev/null +++ b/force-app/main/default/classes/queue/QueueableChainSchedulable.cls @@ -0,0 +1,32 @@ +/** + * PMD False Positives: + * - EmptyCatchBlock: Catch is just for additional safety when creating initial job, and later removing it + **/ +@SuppressWarnings('PMD.EmptyCatchBlock') +public inherited sharing class QueueableChainSchedulable implements Schedulable { + private QueueableChain chain; + + public QueueableChainSchedulable(QueueableChain chain) { + if (chain == null) { + throw new IllegalArgumentException('QueueableChain cannot be null'); + } + this.chain = chain; + } + + public void execute(SchedulableContext ctx) { + chain.enqueueNextJobIfAny(); + System.abortJob(ctx.getTriggerId()); + } + + public static void removeInitialQueuableChainSchedulableIfExists(Id jobId) { + if (jobId == null) { + return; + } + + try { + System.abortJob(jobId); + } catch (Exception e) { + // No action if not exists + } + } +} diff --git a/force-app/main/default/classes/queue/QueueableChainSchedulable.cls-meta.xml b/force-app/main/default/classes/queue/QueueableChainSchedulable.cls-meta.xml new file mode 100644 index 0000000..82775b9 --- /dev/null +++ b/force-app/main/default/classes/queue/QueueableChainSchedulable.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + diff --git a/force-app/main/default/classes/queue/QueueableJob.cls b/force-app/main/default/classes/queue/QueueableJob.cls index 5ff56c2..8c7a8e4 100644 --- a/force-app/main/default/classes/queue/QueueableJob.cls +++ b/force-app/main/default/classes/queue/QueueableJob.cls @@ -1,3 +1,4 @@ +@SuppressWarnings('PMD.ExcessivePublicCount') public abstract class QueueableJob implements Queueable, Comparable { public Id salesforceJobId; public String customJobId; @@ -20,6 +21,7 @@ public abstract class QueueableJob implements Queueable, Comparable { public String parentCustomJobId; public FinalizerContext finalizerCtx; + public String mockId; public Boolean isFinalizer { get { return String.isNotBlank(parentCustomJobId); diff --git a/force-app/main/default/classes/queue/QueueableJob.cls-meta.xml b/force-app/main/default/classes/queue/QueueableJob.cls-meta.xml index 1e7de94..82775b9 100644 --- a/force-app/main/default/classes/queue/QueueableJob.cls-meta.xml +++ b/force-app/main/default/classes/queue/QueueableJob.cls-meta.xml @@ -1,5 +1,5 @@ - 64.0 + 65.0 Active diff --git a/force-app/main/default/classes/queue/QueueableManager.cls b/force-app/main/default/classes/queue/QueueableManager.cls index 1978208..c5f69bf 100644 --- a/force-app/main/default/classes/queue/QueueableManager.cls +++ b/force-app/main/default/classes/queue/QueueableManager.cls @@ -34,8 +34,8 @@ public inherited sharing class QueueableManager { return ctx; } - public Id getQueueableChainBatchId() { - return chain.getQueueableChainBatchId(); + public Id getQueueableChainSchedulableId() { + return chain.getQueueableChainSchedulableId(); } public Async.Result attachFinalizer(QueueableJob job) { @@ -79,11 +79,11 @@ public inherited sharing class QueueableManager { .setJobs(chain.getJobs()) .setNextCustomJobIdFromFirstJob(); - if (shouldExecuteOrReplaceInitialQueueableChainBatch()) { - chain.executeOrReplaceInitialQueueableChainBatchJob(); + if (shouldExecuteOrReplaceInitialQueueableChainSchedulable()) { + chain.executeOrReplaceInitialQueueableChainSchedulableJob(); queueableChainState - .setNextSalesforceJobId(chain.getQueueableChainBatchId()) - .setEnqueueType(EnqueueType.INITIAL_SCHEDULED_BATCH_JOB); + .setNextSalesforceJobId(chain.getQueueableChainSchedulableId()) + .setEnqueueType(EnqueueType.INITIAL_QUEUEABLE_CHAIN_SCHEDULABLE); } else if (!chain.isChainedContext()) { chain.enqueueNextJobIfAny(); queueableChainState @@ -97,7 +97,7 @@ public inherited sharing class QueueableManager { return new Async.Result(job).setQueueableChainState(queueableChainState); } - private Boolean shouldExecuteOrReplaceInitialQueueableChainBatch() { + private Boolean shouldExecuteOrReplaceInitialQueueableChainSchedulable() { return !chain.isChainedContext() && (Limits.getQueueableJobs() >= Limits.getLimitQueueableJobs() || System.isQueueable()); } @@ -110,7 +110,7 @@ public inherited sharing class QueueableManager { } public enum EnqueueType { - INITIAL_SCHEDULED_BATCH_JOB, + INITIAL_QUEUEABLE_CHAIN_SCHEDULABLE, NEW_CHAIN, EXISTING_CHAIN } diff --git a/force-app/main/default/classes/queue/QueueableManager.cls-meta.xml b/force-app/main/default/classes/queue/QueueableManager.cls-meta.xml index 1e7de94..82775b9 100644 --- a/force-app/main/default/classes/queue/QueueableManager.cls-meta.xml +++ b/force-app/main/default/classes/queue/QueueableManager.cls-meta.xml @@ -1,5 +1,5 @@ - 64.0 + 65.0 Active diff --git a/force-app/main/default/classes/queue/QueueableSchedulable.cls-meta.xml b/force-app/main/default/classes/queue/QueueableSchedulable.cls-meta.xml index 1e7de94..82775b9 100644 --- a/force-app/main/default/classes/queue/QueueableSchedulable.cls-meta.xml +++ b/force-app/main/default/classes/queue/QueueableSchedulable.cls-meta.xml @@ -1,5 +1,5 @@ - 64.0 + 65.0 Active diff --git a/force-app/main/default/classes/schedule/SchedulableBuilder.cls-meta.xml b/force-app/main/default/classes/schedule/SchedulableBuilder.cls-meta.xml index 1e7de94..82775b9 100644 --- a/force-app/main/default/classes/schedule/SchedulableBuilder.cls-meta.xml +++ b/force-app/main/default/classes/schedule/SchedulableBuilder.cls-meta.xml @@ -1,5 +1,5 @@ - 64.0 + 65.0 Active diff --git a/force-app/main/default/classes/schedule/SchedulableManager.cls-meta.xml b/force-app/main/default/classes/schedule/SchedulableManager.cls-meta.xml index 1e7de94..82775b9 100644 --- a/force-app/main/default/classes/schedule/SchedulableManager.cls-meta.xml +++ b/force-app/main/default/classes/schedule/SchedulableManager.cls-meta.xml @@ -1,5 +1,5 @@ - 64.0 + 65.0 Active diff --git a/sfdx-project.json b/sfdx-project.json index 9ee47f8..8e14a8d 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -1,12 +1,12 @@ { - "packageDirectories": [ - { - "path": "force-app", - "default": true - } - ], - "name": "BeyondTheCloud", - "namespace": "", - "sfdcLoginUrl": "https://login.salesforce.com", - "sourceApiVersion": "64.0" + "packageDirectories": [ + { + "path": "force-app", + "default": true + } + ], + "name": "BeyondTheCloud", + "namespace": "", + "sfdcLoginUrl": "https://login.salesforce.com", + "sourceApiVersion": "65.0" } diff --git a/website/.vitepress/config.mts b/website/.vitepress/config.mts index 5288d4a..c2077da 100644 --- a/website/.vitepress/config.mts +++ b/website/.vitepress/config.mts @@ -8,7 +8,10 @@ export default defineConfig({ ['link', { rel: 'icon', href: '/favicon.ico' }], [ 'script', - { async: '', src: 'https://www.googletagmanager.com/gtag/js?id=G-53N22KN47H' } + { + async: '', + src: 'https://www.googletagmanager.com/gtag/js?id=G-53N22KN47H' + } ], [ 'script', @@ -45,7 +48,8 @@ export default defineConfig({ items: [ { text: 'Queueable', link: '/api/queueable' }, { text: 'Batchable', link: '/api/batchable' }, - { text: 'Schedulable', link: '/api/schedulable' } + { text: 'Schedulable', link: '/api/schedulable' }, + { text: 'AsyncMock', link: '/api/async-mock' } ] }, { @@ -53,10 +57,11 @@ export default defineConfig({ collapsed: false, items: [ { - text: 'Initial Scheduled Job', + text: 'Initial Queueable Chain Schedulable', link: '/explanations/initial-scheduled-queuable-batch-job' }, - { text: 'Job Cloning', link: '/explanations/job-cloning' } + { text: 'Job Cloning', link: '/explanations/job-cloning' }, + { text: 'Testing Async Jobs', link: '/explanations/testing-async-jobs' } ] } ], diff --git a/website/api/async-mock.md b/website/api/async-mock.md new file mode 100644 index 0000000..14a5a3d --- /dev/null +++ b/website/api/async-mock.md @@ -0,0 +1,434 @@ +# AsyncMock API + +Apex class `AsyncMock.cls`. + +**Common Queueable mocking example:** + +```apex +@IsTest +static void shouldMockQueueableContext() { + AsyncMock.whenQueueable('account-creator') + .thenReturn(new AsyncMock.MockQueueableContext()); + + Test.startTest(); + Async.queueable(new AccountCreatorJob()) + .mockId('account-creator') + .enqueue(); + Test.stopTest(); +} +``` + +**Common Finalizer mocking example:** + +```apex +@IsTest +static void shouldMockFinalizerContext() { + AsyncMock.whenFinalizer('error-handler') + .thenThrow(new DmlException('Parent job failed')); + + Test.startTest(); + Async.queueable(new ParentJobWithFinalizer('error-handler')).enqueue(); + Test.stopTest(); +} +``` + +::: tip +For finalizer mocking, the `mockId` must be set on the finalizer itself (via `attachFinalizer()` inside `work()`), not on the parent job. +::: + +For testing patterns and best practices, see [Testing Async Jobs](/explanations/testing-async-jobs). + +## Methods + +The following are methods for using AsyncMock in tests: + +[**INIT - Finalizer**](#init---finalizer) + +- [`whenFinalizer(String mockId)`](#whenfinalizer) +- [`whenFinalizerDefault()`](#whenfinalizerdefault) + +[**INIT - Queueable**](#init---queueable) + +- [`whenQueueable(String mockId)`](#whenqueueable) +- [`whenQueueableDefault()`](#whenqueueabledefault) + +[**Build - FinalizerMockSetup**](#build---finalizermocksetup) + +- [`thenReturn(FinalizerContext ctx)`](#thenreturn-finalizercontext) +- [`thenReturn(ParentJobResult result)`](#thenreturn-parentjobresult) +- [`thenThrow(Exception ex)`](#thenthrow) + +[**Build - QueueableMockSetup**](#build---queueablemocksetup) + +- [`thenReturn(QueueableContext ctx)`](#thenreturn-queueablecontext) +- [`thenReturn(Id jobId)`](#thenreturn-id) + +[**Utility**](#utility) + +- [`reset()`](#reset) +- [`hasFinalizerMock(String mockId)`](#hasfinalizermock) +- [`hasQueueableMock(String mockId)`](#hasqueueablemock) +- [`getFinalizerContext(String mockId)`](#getfinalizercontext) +- [`getQueueableContext(String mockId)`](#getqueueablecontext) + +[**Mock Context Classes**](#mock-context-classes) + +- [`MockFinalizerContext`](#mockfinalizercontext) +- [`MockQueueableContext`](#mockqueueablecontext) + +### INIT - Finalizer + +#### whenFinalizer + +Sets up a mock for a specific finalizer identified by mockId. + +**Signature** + +```apex +static FinalizerMockSetup whenFinalizer(String mockId); +``` + +**Example** + +```apex +AsyncMock.whenFinalizer('error-handler') + .thenReturn(ParentJobResult.SUCCESS); +``` + +#### whenFinalizerDefault + +Sets up a default mock that applies when no specific mockId matches or when a specific mock is exhausted. + +**Signature** + +```apex +static FinalizerMockSetup whenFinalizerDefault(); +``` + +**Example** + +```apex +AsyncMock.whenFinalizerDefault() + .thenReturn(ParentJobResult.SUCCESS); + +Test.startTest(); +Async.queueable(new ParentJobWithFinalizer('job-1')).enqueue(); +Async.queueable(new ParentJobWithFinalizer('job-2')).enqueue(); +Test.stopTest(); +``` + +### INIT - Queueable + +#### whenQueueable + +Sets up a mock for a specific queueable job identified by mockId. + +**Signature** + +```apex +static QueueableMockSetup whenQueueable(String mockId); +``` + +**Example** + +```apex +AsyncMock.whenQueueable('account-creator') + .thenReturn(new AsyncMock.MockQueueableContext()); +``` + +#### whenQueueableDefault + +Sets up a default mock that applies when no specific mockId matches or when a specific mock is exhausted. + +**Signature** + +```apex +static QueueableMockSetup whenQueueableDefault(); +``` + +**Example** + +```apex +AsyncMock.whenQueueableDefault() + .thenReturn(new AsyncMock.MockQueueableContext()); +``` + +### Build - FinalizerMockSetup + +#### thenReturn (FinalizerContext) + +Adds a `FinalizerContext` to the mock queue. Each call to `getFinalizerContext` consumes one context from the queue (FIFO). + +**Signature** + +```apex +FinalizerMockSetup thenReturn(FinalizerContext ctx); +``` + +**Example** + +```apex +AsyncMock.whenFinalizer('multi-test') + .thenReturn(new AsyncMock.MockFinalizerContext() + .setResult(ParentJobResult.SUCCESS)) + .thenReturn(new AsyncMock.MockFinalizerContext() + .setResult(ParentJobResult.UNHANDLED_EXCEPTION)); +``` + +#### thenReturn (ParentJobResult) + +Convenience method that creates a `MockFinalizerContext` with the specified result. + +**Signature** + +```apex +FinalizerMockSetup thenReturn(ParentJobResult result); +``` + +**Example** + +```apex +AsyncMock.whenFinalizer('my-job') + .thenReturn(ParentJobResult.SUCCESS) + .thenReturn(ParentJobResult.UNHANDLED_EXCEPTION) + .thenReturn(ParentJobResult.SUCCESS); +``` + +#### thenThrow + +Creates a `MockFinalizerContext` with `UNHANDLED_EXCEPTION` result and the specified exception. + +**Signature** + +```apex +FinalizerMockSetup thenThrow(Exception ex); +``` + +**Example** + +```apex +AsyncMock.whenFinalizer('error-handler') + .thenThrow(new DmlException('Parent job failed')); + +Test.startTest(); +Async.queueable(new ParentJobWithFinalizer('error-handler')).enqueue(); +Test.stopTest(); + +Account errorLog = [SELECT Name, Description FROM Account LIMIT 1]; +Assert.areEqual('Parent job failed', errorLog.Description); +``` + +### Build - QueueableMockSetup + +#### thenReturn (QueueableContext) + +Adds a `QueueableContext` to the mock queue. Each call to `getQueueableContext` consumes one context from the queue (FIFO). + +**Signature** + +```apex +QueueableMockSetup thenReturn(QueueableContext ctx); +``` + +**Example** + +```apex +AsyncMock.whenQueueable('my-job') + .thenReturn(new AsyncMock.MockQueueableContext().setJobId('707xx0000000001')); +``` + +#### thenReturn (Id) + +Convenience method that creates a `MockQueueableContext` with the specified job ID. + +**Signature** + +```apex +QueueableMockSetup thenReturn(Id jobId); +``` + +**Example** + +```apex +AsyncMock.whenQueueable('my-job') + .thenReturn('707xx0000000001AAA'); +``` + +### Utility + +#### reset + +Clears all mock setups (both specific and default mocks). + +**Signature** + +```apex +static void reset(); +``` + +**Example** + +```apex +AsyncMock.whenFinalizer('test').thenReturn(ParentJobResult.SUCCESS); +AsyncMock.whenQueueable('test').thenReturn(new AsyncMock.MockQueueableContext()); + +AsyncMock.reset(); + +Assert.isNull(AsyncMock.getFinalizerContext('test')); +Assert.isNull(AsyncMock.getQueueableContext('test')); +``` + +#### hasFinalizerMock + +Checks if a finalizer mock exists for the given mockId or if a default mock is configured. + +**Signature** + +```apex +static Boolean hasFinalizerMock(String mockId); +``` + +**Example** + +```apex +AsyncMock.whenFinalizer('my-job').thenReturn(ParentJobResult.SUCCESS); + +Assert.isTrue(AsyncMock.hasFinalizerMock('my-job')); +Assert.isFalse(AsyncMock.hasFinalizerMock('other-job')); +``` + +#### hasQueueableMock + +Checks if a queueable mock exists for the given mockId or if a default mock is configured. + +**Signature** + +```apex +static Boolean hasQueueableMock(String mockId); +``` + +**Example** + +```apex +AsyncMock.whenQueueable('my-job').thenReturn(new AsyncMock.MockQueueableContext()); + +Assert.isTrue(AsyncMock.hasQueueableMock('my-job')); +Assert.isFalse(AsyncMock.hasQueueableMock('other-job')); +``` + +#### getFinalizerContext + +Retrieves and removes the next `FinalizerContext` from the mock queue. Falls back to default mock if specific mock is exhausted. + +**Signature** + +```apex +static FinalizerContext getFinalizerContext(String mockId); +``` + +**Example** + +```apex +AsyncMock.whenFinalizerDefault().thenReturn(ParentJobResult.SUCCESS); +AsyncMock.whenFinalizer('special').thenThrow(new DmlException('Error')); + +FinalizerContext ctx1 = AsyncMock.getFinalizerContext('special'); +FinalizerContext ctx2 = AsyncMock.getFinalizerContext('special'); + +Assert.areEqual(ParentJobResult.UNHANDLED_EXCEPTION, ctx1.getResult()); +Assert.areEqual(ParentJobResult.SUCCESS, ctx2.getResult()); // Falls back to default +``` + +#### getQueueableContext + +Retrieves and removes the next `QueueableContext` from the mock queue. Falls back to default mock if specific mock is exhausted. + +**Signature** + +```apex +static QueueableContext getQueueableContext(String mockId); +``` + +**Example** + +```apex +AsyncMock.whenQueueableDefault().thenReturn(new AsyncMock.MockQueueableContext()); +AsyncMock.whenQueueable('special').thenReturn(new AsyncMock.MockQueueableContext()); + +QueueableContext ctx1 = AsyncMock.getQueueableContext('special'); +QueueableContext ctx2 = AsyncMock.getQueueableContext('special'); + +Assert.isNotNull(ctx1); +Assert.isNotNull(ctx2); // Falls back to default +``` + +### Mock Context Classes + +#### MockFinalizerContext + +Implements `System.FinalizerContext` for test scenarios. + +**Signature** + +```apex +public class MockFinalizerContext implements System.FinalizerContext +``` + +**Build Methods** + +| Method | Description | +|--------|-------------| +| `setResult(ParentJobResult result)` | Sets the parent job result | +| `setException(Exception ex)` | Sets exception and auto-sets result to `UNHANDLED_EXCEPTION` | +| `setJobId(Id jobId)` | Sets the async apex job ID | + +**Interface Methods** + +| Method | Description | +|--------|-------------| +| `getResult()` | Returns the configured `ParentJobResult` | +| `getException()` | Returns the configured exception | +| `getAsyncApexJobId()` | Returns the configured job ID | +| `getRequestId()` | Returns `'mock-request-id'` | + +**Example** + +```apex +ErrorHandlerFinalizer finalizer = new ErrorHandlerFinalizer(); +finalizer.finalizerCtx = new AsyncMock.MockFinalizerContext() + .setResult(ParentJobResult.UNHANDLED_EXCEPTION) + .setException(new DmlException('Direct test error')); + +finalizer.work(); +``` + +#### MockQueueableContext + +Implements `System.QueueableContext` for test scenarios. + +**Signature** + +```apex +public class MockQueueableContext implements System.QueueableContext +``` + +**Build Methods** + +| Method | Description | +|--------|-------------| +| `setJobId(Id jobId)` | Sets the job ID | + +**Interface Methods** + +| Method | Description | +|--------|-------------| +| `getJobId()` | Returns the configured job ID | + +**Example** + +```apex +AccountCreatorJob job = new AccountCreatorJob('Direct Test'); +job.queueableCtx = new AsyncMock.MockQueueableContext(); + +job.work(); +``` diff --git a/website/api/batchable.md b/website/api/batchable.md index ab50887..9e0051e 100644 --- a/website/api/batchable.md +++ b/website/api/batchable.md @@ -2,7 +2,7 @@ Apex classes `BatchableBuilder.cls` and `BatchableManager.cls`. -Common Batchable example: +**Common Batchable example:** ```apex Database.Batchable job = new MyBatchJob(); @@ -89,7 +89,7 @@ Async.batchable(new MyBatchJob()) #### asSchedulable Converts the batch builder to a schedulable builder for cron-based scheduling. -For scheduling, look into the [SchedulableBuilder](/api/schedulable.md) API. +See [Schedulable API](/api/schedulable) for scheduling options. **Signature** @@ -122,6 +122,6 @@ Async.Result execute(); Async.Result result = Async.batchable(new MyBatchJob()) .scopeSize(100) .execute(); - -result.salesforceJobId; // MyBatchJob Salesforce Job Id ``` + +Returns `result.salesforceJobId` containing the Salesforce Job Id. diff --git a/website/api/queueable.md b/website/api/queueable.md index d95883f..4f65ad7 100644 --- a/website/api/queueable.md +++ b/website/api/queueable.md @@ -1,7 +1,8 @@ # Queueable API -Apex classes `QueueableBuilder.cls`, `QueueableManager.cls`, and -`QueueableJob.cls`. +Apex classes `QueueableBuilder.cls`, `QueueableManager.cls`, and `QueueableJob.cls`. + +For testing patterns and best practices, see [Testing Async Jobs](/explanations/testing-async-jobs). **Common Queueable example:** @@ -12,10 +13,10 @@ Async.Result result = Async.queueable(job) .delay(2) .continueOnJobExecuteFail() .enqueue(); - -result.customJobId; // MyQueueableJob Custom Job Id ``` +Returns `result.customJobId` containing MyQueueableJob's unique Custom Job Id. + **Common QueueableJob class example:** ```apex @@ -58,6 +59,7 @@ The following are methods for using Async with Queueable jobs: - [`chain()`](#chain) - [`chain(QueueableJob job)`](#chain-next-job) - [`asSchedulable()`](#asschedulable) +- [`mockId(String mockId)`](#mockid) [**Execute**](#execute) @@ -67,7 +69,7 @@ The following are methods for using Async with Queueable jobs: [**Context**](#context) - [`getQueueableJobContext()`](#getqueueablejobcontext) -- [`getQueueableChainBatchId()`](#getqueueablechainbatchid) +- [`getQueueableChainSchedulableId()`](#getqueueablechainschedulableid) - [`getCurrentQueueableChainState()`](#getcurrentqueueablechainstate) ### INIT @@ -229,9 +231,10 @@ QueueableBuilder chain(); ```apex Async.Result result = Async.queueable(new MyQueueableJob()) .chain(); -result.customJobId; // MyQueueableJob unique Custom Job Id ``` +Returns `result.customJobId` containing MyQueueableJob's unique Custom Job Id. + #### chain next job Adds the Queueable Job to the chain after previous job. All jobs in chain will @@ -248,15 +251,14 @@ QueueableBuilder chain(QueueableJob job); ```apex Async.Result result = Async.queueable(new MyQueueableJob()) .chain(new MyOtherQueueableJob()); -result.customJobId; // MyOtherQueueableJob Unique Custom Job Id. -// To obtain MyQueueableJob Unique Custom Job Id use chain() method separately ``` +Returns `result.customJobId` containing MyOtherQueueableJob's unique Custom Job Id. To obtain MyQueueableJob's Id, use `chain()` method separately. + #### asSchedulable Converts the queueable builder to a schedulable builder for cron-based -scheduling. For scheduling, look into the -[SchedulableBuilder](/api/schedulable.md) API. +scheduling. See [Schedulable API](/api/schedulable) for scheduling options. **Signature** @@ -271,6 +273,33 @@ Async.queueable(new MyQueueableJob()) .asSchedulable(); ``` +#### mockId + +Sets a mock identifier for testing with AsyncMock. When the job executes during +a test, the framework will inject the corresponding mock context. See +[AsyncMock API](/api/async-mock) for details. + +**Signature** + +```apex +QueueableBuilder mockId(String mockId); +``` + +**Example** + +```apex +// For queueable context mocking +AsyncMock.whenQueueable('account-creator') + .thenReturn(new AsyncMock.MockQueueableContext()); + +Async.queueable(new AccountCreatorJob()) + .mockId('account-creator') + .enqueue(); + +// For finalizer mocking, use mockId when attaching finalizer inside work() +// See AsyncMock API for finalizer patterns +``` + ### Execute #### enqueue @@ -289,17 +318,26 @@ Async.Result enqueue(); Async.Result result = Async.queueable(new MyQueueableJob()) .priority(5) .enqueue(); - -result.salesforceJobId; // MyQueueableJob Saleforce Job Id of either Queuable Job or Initial Scheduled Job, if MyQueueableJob was the enqueued one in chain, otherwise empty -result.customJobId; // MyQueueableJob Unique Custom Job Id. -result.asyncType; // Async.AsyncType.QUEUEABLE -result.queueableChainState; // queueable chain state -result.queueableChainState.jobs; // All jobs that were chained or in chain, including finalizers and processed jobs -result.queueableChainState.nextSalesforceJobId; // Salesforce Job Id that will run next from chain -result.queueableChainState.nextCustomJobId; // Custom Job Id that will run next from chain -result.queueableChainState.enqueueType; // QueueableManager.EnqueueType - determine how the chain was enqueued, either added to currently running chain (EXISTING_CHAIN), enqueued as separate chain (NEW_CHAIN), or scheduled by initial job (INITIAL_SCHEDULED_BATCH_JOB) ``` +**Result properties:** + +| Property | Description | +|----------|-------------| +| `salesforceJobId` | Salesforce Job Id of either Queueable Job or Initial Queueable Chain Schedulable (empty if job was not the enqueued one in chain) | +| `customJobId` | Unique Custom Job Id | +| `asyncType` | `Async.AsyncType.QUEUEABLE` | +| `queueableChainState` | Chain state object (see below) | + +**`queueableChainState` properties:** + +| Property | Description | +|----------|-------------| +| `jobs` | All jobs in chain including finalizers and processed jobs | +| `nextSalesforceJobId` | Salesforce Job Id that will run next from chain | +| `nextCustomJobId` | Custom Job Id that will run next from chain | +| `enqueueType` | How the chain was enqueued: `EXISTING_CHAIN`, `NEW_CHAIN`, or `INITIAL_QUEUEABLE_CHAIN_SCHEDULABLE` | + #### attachFinalizer Attaches a finalizer job to run after the current job completes. Can only be @@ -317,10 +355,10 @@ Async.Result attachFinalizer(); // Inside a QueueableJob's work() method Async.Result result = Async.queueable(new MyFinalizerJob()) .attachFinalizer(); - -result.customJobId; // MyQueueableJob unique Custom Job Id ``` +Returns `result.customJobId` containing the finalizer's unique Custom Job Id. + ### Context #### getQueueableJobContext @@ -338,27 +376,35 @@ Async.QueueableJobContext getQueueableJobContext(); ```apex Async.QueueableJobContext ctx = Async.getQueueableJobContext(); -QueueableJob currentJob = ctx.currentJob; -QueueableContext sfContext = ctx.queueableCtx; ``` -#### getQueueableChainBatchId +**Context properties:** + +| Property | Description | +|----------|-------------| +| `ctx.currentJob` | Current `QueueableJob` instance | +| `ctx.queueableCtx` | Salesforce `QueueableContext` | +| `ctx.finalizerCtx` | Salesforce `FinalizerContext` (available in finalizers) | -Gets the ID of the QueueableChain batch job if the current execution is part of -a batch-based chain. +#### getQueueableChainSchedulableId + +Gets the ID of the initial Queueable Chain Schedulable if the current execution is part of +a scheduled-based chain. **Signature** ```apex -Id getQueueableChainBatchId(); +Id getQueueableChainSchedulableId(); ``` **Example** ```apex -Id batchId = Async.getQueueableChainBatchId(); // Initial Scheduled Batch Job +Id schedulableId = Async.getQueueableChainSchedulableId(); ``` +Returns the Id of the Initial Queueable Chain Schedulable. + #### getCurrentQueueableChainState Gets details about the current Queueable Chain. @@ -373,8 +419,13 @@ QueueableChainState getCurrentQueueableChainState(); ```apex QueueableChainState currentChain = Async.getCurrentQueueableChainState(); -currentChain.jobs; // All jobs in chain including processed ones and finalizers -currentChain.nextSalesforceJobId; // Salesforce Job Id that will run next from chain, can be empty if chain not enqueued or from Chain context -currentChain.nextCustomJobId; // Custom Job Id that will run next from chain -currentChain.enqueueType; // empty, value set during enqueue() method ``` + +**Chain state properties:** + +| Property | Description | +|----------|-------------| +| `jobs` | All jobs in chain including processed ones and finalizers | +| `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 | diff --git a/website/api/schedulable.md b/website/api/schedulable.md index 1d26d15..f3392a0 100644 --- a/website/api/schedulable.md +++ b/website/api/schedulable.md @@ -2,7 +2,7 @@ Apex classes `SchedulableBuilder.cls`, `SchedulableManager.cls`, and `CronBuilder.cls`. -Common Schedulable example: +**Common Schedulable example:** ```apex Schedulable job = new MySchedulableJob(); @@ -465,11 +465,8 @@ List schedule(); ```apex List results = Async.schedulable(new MySchedulableJob()) .name('Every Hour Processing') - .cronExpression( - new CronBuilder() - .everyHour(1) - ) + .cronExpression(new CronBuilder().everyHour(1)) .schedule(); +``` -results.get(0).salesforceJobId; // MySchedulableJob Salesforce Job Id -``` \ No newline at end of file +Returns a list with one result per cron expression. Each `result.salesforceJobId` contains the Salesforce Job Id. diff --git a/website/explanations/initial-scheduled-queuable-batch-job.md b/website/explanations/initial-scheduled-queuable-batch-job.md index 2b8c15b..870d1ac 100644 --- a/website/explanations/initial-scheduled-queuable-batch-job.md +++ b/website/explanations/initial-scheduled-queuable-batch-job.md @@ -2,25 +2,33 @@ outline: deep --- -# Initial Scheduled Queueable Batch Job Explanation +# Initial Queueable Chain Schedulable Explanation ## TL;DR Due to the fact that we cannot: -- determine if current enqueued queueable job is the last one in the Apex transaction, -- pass QueueableChain job as reference to `System.enqueueJob()`, and later in Apex transaction add new jobs to this chain, -- abort queueable job and preserve the Queueable Job limits (running `System.enqueueJob()` and later `System.abortJob()` doesn't revert the Queueable Job limits), +- determine if current enqueued queueable job is the last one in the Apex + transaction, +- pass QueueableChain job as reference to `System.enqueueJob()`, and later in + Apex transaction add new jobs to this chain, +- abort queueable job and preserve the Queueable Job limits (running + `System.enqueueJob()` and later `System.abortJob()` doesn't revert the + Queueable Job limits), -The only option is to *somehow* enqueue a queueable chain, and in case of next `System.enqueueJob()` call, abort that job and enqueue a new one with additional job. +The only option is to _somehow_ enqueue a queueable chain, and in case of next +`System.enqueueJob()` call, abort that job and enqueue a new one with additional +job. -This *somehow* approach, is exactly what the initial scheduled queueable batch job implementation does. +This _somehow_ approach, is exactly what the initial queueable chain schedulable +implementation does. -## Why Do We Need QueueableChainBatch? +## Why Do We Need QueueableChainSchedulable? ### The Challenge: Queueable Job Limits Salesforce has strict limits on queueable jobs: + - **Maximum 50 queueable jobs** can be enqueued per transaction - Once you hit this limit, `System.enqueueJob()` will throw an exception - Using `System.abortJob()` does not free up the queueable job limits @@ -28,14 +36,18 @@ Salesforce has strict limits on queueable jobs: ### The Goal: Efficient Job Processing -To be efficient, Async Lib tries to enqueue as many queueable jobs as possible in the synchronous context. This means: +To be efficient, Async Lib tries to enqueue as many queueable jobs as possible +in the synchronous context. This means: + 1. Enqueue jobs normally using `System.enqueueJob()` (jobs 1-50) -2. Once reaching 50 queueable jobs, switch to an alternative approach for the remaining jobs -3. Schedule `QueueableChainBatch` to handle jobs beyond the 50-job limit +2. Once reaching 50 queueable jobs, switch to an alternative approach for the + remaining jobs +3. Schedule `QueueableChainSchedulable` to handle jobs beyond the 50-job limit ### The Technical Problem -The core issue is **we don't know how many more jobs will be enqueued** during the current transaction: +The core issue is **we don't know how many more jobs will be enqueued** during +the current transaction: ```apex // We're at 49 jobs enqueued @@ -50,6 +62,7 @@ Async.queueable(new JobXXXXX()).enqueue(); // How many more...? ``` **Why we can't just enqueue the chain at job #50:** + - `System.enqueueJob()` only passes the **current state** of the job - If we enqueue a `QueueableJob` with chain details as the 50th job - And later try to add job #51 to that chain @@ -58,48 +71,42 @@ Async.queueable(new JobXXXXX()).enqueue(); // How many more...? ### Failed Approach: Enqueue + Abort A logical solution might be: + 1. Enqueue a job with the current chain state -2. If more jobs come in, abort the previous job and enqueue a new one with updated chain +2. If more jobs come in, abort the previous job and enqueue a new one with + updated chain **However, this doesn't work because:** -- Using `System.enqueueJob()` followed by `System.abortJob()` in the same transaction + +- Using `System.enqueueJob()` followed by `System.abortJob()` in the same + transaction - **Still consumes the queueable job limits** - The limits are not restored when you abort - This means you quickly run out of limit slots -### The Solution: Scheduled Batch Jobs - -**Database.executeBatch() has different behavior:** -- Batch jobs are **not tied to the same queueable job limits** -- When using `System.abortJob()` on a batch job, **the limits are properly restored** -- This allows us to execute, abort, and re-execute as many times as needed +### The Solution: Schedulable context -**How the QueueableChainBatch works:** -1. When we hit the queueable limit, schedule a batch job with the current chain state -2. If more jobs are added during the transaction: - - Abort the previous batch job - - Schedule a new batch job with the updated chain (including new jobs) -3. Repeat as needed until the transaction ends -4. The final batch job executes with all the accumulated jobs +**System.schedule() has different behavior:** -### Why Scheduled Instead of Immediate Batch? - -Initially, we tried executing batch jobs immediately, but we encountered another Salesforce limitation: +- Schedulable are **not tied to the same queueable job limits** +- When using `System.abortJob()` on a schedulable, **the limits are properly + restored** +- This allows us to execute, abort, and re-execute as many times as needed -**Batch Job Execution Limits:** -- There is no option to execute batch jobs from `start()` and `execute()` methods (Full Error Message: "Database.executeBatch cannot be called from a batch start, batch execute, or future method") -- There is a limit of enqueueing only one Queueable job in a batch context. -- This means in a batch context, in case of more than one queueable job being enqueued, initial batch job will fail to execute. +**How the QueueableChainSchedulable works:** -**The Scheduled Solution:** -- Instead of executing the batch immediately, we **schedule it to run 1 minute in the future** -- This bypasses the batch-from-batch execution limits -- The scheduled job runs in a clean context without the restrictions -- This approach handles all edge cases reliably +1. When we hit the queueable limit, schedule a initial queueable chain + schedulable with the current chain state **to run 1 minute in future** +2. If more queueable jobs are added during the transaction: + - Abort the previous schedulable + - Schedule a new initial queueable chain schedulable with the updated chain + (including new queueable jobs) +3. Repeat as needed until the transaction ends +4. The final schedulable executes with all the accumulated queueable jobs ## Real-World Example -Here's what happens when you enqueue 75 jobs: +Here's what happens when you enqueue 75 queueable jobs: ```apex // In your code @@ -109,18 +116,20 @@ for (Integer i = 1; i <= 75; i++) { ``` **Behind the scenes:** + 1. **Jobs 1-50**: Enqueued normally using `System.enqueueJob()` -2. **Job 51**: Triggers QueueableChainBatch creation, scheduled for +1 minute -3. **Jobs 52-75**: Each addition aborts previous batch and schedules new one with updated chain -4. **Final result**: One scheduled batch job containing jobs 51-75 in the chain +2. **Job 51**: Triggers QueueableChainSchedulable creation, scheduled for +1 + minute +3. **Jobs 52-75**: Each addition aborts previous schedulable and schedules new + one with updated chain +4. **Final result**: One queueable chain schedulable is created, containing + queueable jobs 51-75 in the chain ## Benefits of This Approach ✅ **No Limit Errors**: Never throws "Too many queueable jobs" exceptions ✅ **Efficient Processing**: Uses direct queueable jobs when possible -✅ **Automatic Fallback**: Seamlessly switches to batch processing when needed +✅ **Automatic Fallback**: Seamlessly switches to schedulable processing when +needed ✅ **Complete Chain Execution**: All jobs execute in the correct order -✅ **Error Recovery**: Handles various Salesforce governor limit scenarios - - - +✅ **Error Recovery**: Handles various Salesforce governor limit scenarios diff --git a/website/explanations/testing-async-jobs.md b/website/explanations/testing-async-jobs.md new file mode 100644 index 0000000..8c9e2e8 --- /dev/null +++ b/website/explanations/testing-async-jobs.md @@ -0,0 +1,327 @@ +--- +outline: deep +--- + +# Testing Async Jobs + +## TL;DR + +Testing asynchronous jobs in Salesforce presents unique challenges because +`QueueableContext` and `FinalizerContext` are system-provided during runtime. +AsyncMock provides mock implementations of these context interfaces, enabling you to: + +- Test finalizer error handling without triggering actual job failures +- Test queueable job behavior with controlled context +- Direct unit testing of job `work()` methods without `Test.startTest()/stopTest()` +- Queue-based mock consumption for testing multiple invocations + +View the full [AsyncMock API](/api/async-mock) documentation for method details. + +## The Testing Challenge + +### Why Standard Testing Falls Short + +When testing async jobs traditionally, you face these limitations: + +1. **Limited Context Control**: You cannot control what `FinalizerContext` returns +2. **No Exception Simulation**: Cannot easily simulate `ParentJobResult.UNHANDLED_EXCEPTION` +3. **Integration-Only Testing**: Must use `Test.startTest()/stopTest()` for all scenarios +4. **No Multiple Invocation Testing**: Hard to test a job that handles multiple calls differently + +### Traditional Approach + +```apex +@IsTest +static void traditionalTest() { + Test.startTest(); + Async.queueable(new MyJob()).enqueue(); + Test.stopTest(); + + // Can only verify end results, not intermediate states + // Cannot test error handling paths + // Cannot test finalizer behavior with exceptions +} +``` + +### The AsyncMock Solution + +AsyncMock provides: + +1. **Mock Context Classes**: Full implementations of Salesforce context interfaces +2. **Fluent Setup API**: Easy-to-read test setup with `whenFinalizer().thenReturn()` +3. **Queue-Based Mocks**: Multiple mock responses for sequential calls +4. **Default Fallback**: Default mocks when specific mockId isn't found + +## Testing Patterns + +### Pattern 1: Testing Finalizer Error Handling + +Test how your finalizer handles job failures without actually causing a failure. + +```apex +public class ErrorHandlerFinalizer extends QueueableJob.Finalizer { + public override void work() { + FinalizerContext ctx = this.finalizerCtx; + if (ctx?.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) { + insert new Account( + Name = 'Error Log', + Description = ctx.getException()?.getMessage() + ); + } + } +} + +public class ParentJobWithFinalizer extends QueueableJob { + private String mockId; + + public ParentJobWithFinalizer(String mockId) { + this.mockId = mockId; + } + + public override void work() { + Async.queueable(new ErrorHandlerFinalizer()) + .mockId(mockId) + .attachFinalizer(); + } +} +``` + +**Test with mocked exception:** + +```apex +@IsTest +static void shouldHandleJobFailure() { + AsyncMock.whenFinalizer('error-handler') + .thenThrow(new DmlException('Parent job failed')); + + Test.startTest(); + Async.queueable(new ParentJobWithFinalizer('error-handler')).enqueue(); + Test.stopTest(); + + Account errorLog = [SELECT Name, Description FROM Account LIMIT 1]; + Assert.areEqual('Error Log', errorLog.Name); + Assert.areEqual('Parent job failed', errorLog.Description); +} +``` + +**Test with success result:** + +```apex +@IsTest +static void shouldNotCreateLogOnSuccess() { + AsyncMock.whenFinalizer('error-handler') + .thenReturn(ParentJobResult.SUCCESS); + + Test.startTest(); + Async.queueable(new ParentJobWithFinalizer('error-handler')).enqueue(); + Test.stopTest(); + + Assert.areEqual(0, [SELECT COUNT() FROM Account]); +} +``` + +### Pattern 2: Direct Unit Testing + +Test job logic directly without `Test.startTest()/stopTest()` by injecting mock contexts. + +```apex +public class AccountCreatorJob extends QueueableJob { + private String accountName; + + public AccountCreatorJob(String accountName) { + this.accountName = accountName; + } + + public override void work() { + Id jobId = this.queueableCtx?.getJobId(); + insert new Account(Name = accountName, Description = 'Job: ' + jobId); + } +} +``` + +**Direct test:** + +```apex +@IsTest +static void shouldCreateAccountDirectly() { + AccountCreatorJob job = new AccountCreatorJob('Direct Test'); + job.queueableCtx = new AsyncMock.MockQueueableContext(); + + job.work(); + + Account acc = [SELECT Name, Description FROM Account LIMIT 1]; + Assert.areEqual('Direct Test', acc.Name); + Assert.isNotNull(acc.Description); +} +``` + +**Finalizer direct test:** + +```apex +@IsTest +static void shouldTestFinalizerDirectly() { + ErrorHandlerFinalizer finalizer = new ErrorHandlerFinalizer(); + finalizer.finalizerCtx = new AsyncMock.MockFinalizerContext() + .setResult(ParentJobResult.UNHANDLED_EXCEPTION) + .setException(new DmlException('Direct test error')); + + finalizer.work(); + + Account errorLog = [SELECT Name, Description FROM Account LIMIT 1]; + Assert.areEqual('Error Log', errorLog.Name); + Assert.areEqual('Direct test error', errorLog.Description); +} +``` + +### Pattern 3: Multiple Invocation Testing + +Test jobs that should behave differently on sequential calls using queue-based mocks. + +```apex +@IsTest +static void shouldHandleMultipleInvocations() { + AsyncMock.whenFinalizer('multi-test') + .thenReturn(ParentJobResult.SUCCESS) + .thenThrow(new DmlException('Second call failed')) + .thenReturn(ParentJobResult.SUCCESS); + + Test.startTest(); + Async.queueable(new ParentJobWithFinalizer('multi-test')).enqueue(); + Async.queueable(new ParentJobWithFinalizer('multi-test')).enqueue(); + Async.queueable(new ParentJobWithFinalizer('multi-test')).enqueue(); + Test.stopTest(); + + // Only the second call created an error log + Assert.areEqual(1, [SELECT COUNT() FROM Account]); + Assert.areEqual( + 'Second call failed', + [SELECT Description FROM Account LIMIT 1].Description + ); +} +``` + +### Pattern 4: Default Mock Fallback + +Use default mocks for jobs without specific mock IDs. + +```apex +@IsTest +static void shouldUseDefaultMock() { + AsyncMock.whenFinalizerDefault() + .thenReturn(ParentJobResult.SUCCESS); + + Test.startTest(); + // All these jobs use the default mock + Async.queueable(new ParentJobWithFinalizer('job-1')).enqueue(); + Async.queueable(new ParentJobWithFinalizer('job-2')).enqueue(); + Test.stopTest(); + + Assert.areEqual(0, [SELECT COUNT() FROM Account]); +} +``` + +**Combining specific and default mocks:** + +```apex +@IsTest +static void shouldFallbackToDefault() { + AsyncMock.whenFinalizerDefault().thenReturn(ParentJobResult.SUCCESS); + AsyncMock.whenFinalizer('special').thenThrow(new DmlException('Error')); + + // First call uses specific mock, then falls back to default + FinalizerContext ctx1 = AsyncMock.getFinalizerContext('special'); + FinalizerContext ctx2 = AsyncMock.getFinalizerContext('special'); + + Assert.areEqual(ParentJobResult.UNHANDLED_EXCEPTION, ctx1.getResult()); + Assert.areEqual(ParentJobResult.SUCCESS, ctx2.getResult()); // Falls back to default +} +``` + +## Best Practices + +### 1. Use mockId for Targeted Mocking + +Always use meaningful mock IDs that describe the test scenario: + +```apex +// Good +AsyncMock.whenFinalizer('payment-error-handler').thenThrow(new PaymentException()); +AsyncMock.whenFinalizer('notification-sender').thenReturn(ParentJobResult.SUCCESS); + +// Avoid generic IDs +AsyncMock.whenFinalizer('test').thenThrow(new Exception()); +``` + +### 2. Reset Mocks When Needed + +If running multiple tests that share mock state, reset between tests: + +```apex +@IsTest +static void testOne() { + AsyncMock.whenFinalizer('test').thenReturn(ParentJobResult.SUCCESS); + // ... test code +} + +@IsTest +static void testTwo() { + AsyncMock.reset(); // Clean slate + AsyncMock.whenFinalizer('test').thenThrow(new DmlException()); + // ... test code +} +``` + +### 3. Prefer Direct Testing When Possible + +Direct testing is faster and more focused: + +```apex +// Faster - direct unit test +@IsTest +static void directTest() { + MyJob job = new MyJob(); + job.queueableCtx = new AsyncMock.MockQueueableContext(); + job.work(); + // Assert results +} + +// Slower - full integration test +@IsTest +static void integrationTest() { + Test.startTest(); + Async.queueable(new MyJob()).enqueue(); + Test.stopTest(); + // Assert results +} +``` + +### 4. Test Both Success and Failure Paths + +Always verify your jobs handle both outcomes: + +```apex +@IsTest +static void shouldHandleSuccess() { + AsyncMock.whenFinalizer('handler').thenReturn(ParentJobResult.SUCCESS); + // Test success path +} + +@IsTest +static void shouldHandleFailure() { + AsyncMock.whenFinalizer('handler').thenThrow(new DmlException('Failed')); + // Test error handling path +} +``` + +## Summary + +AsyncMock enables comprehensive testing of async jobs by providing mock implementations of Salesforce context interfaces. Key capabilities: + +| Feature | Benefit | +|---------|---------| +| Mock contexts | Control job behavior in tests | +| Queue-based mocks | Test sequential call patterns | +| Default fallback | Simplify multi-job test setup | +| Direct testing | Faster, focused unit tests | + +Use these patterns to ensure your async jobs are thoroughly tested and resilient to both success and failure scenarios. diff --git a/website/getting-started.md b/website/getting-started.md index 0b4c0a5..0990993 100644 --- a/website/getting-started.md +++ b/website/getting-started.md @@ -4,7 +4,10 @@ outline: deep # Getting Started -Async Lib is a powerful Salesforce Apex framework that provides an elegant solution for managing asynchronous processes. It eliminates common limitations like "Too many queueable jobs" errors and offers a unified API for queueable, batchable, and schedulable jobs. +Async Lib is a powerful Salesforce Apex framework that provides an elegant +solution for managing asynchronous processes. It eliminates common limitations +like "Too many queueable jobs" errors and offers a unified API for queueable, +batchable, and schedulable jobs. ## Why Async Lib? @@ -49,13 +52,20 @@ Async Lib is a powerful Salesforce Apex framework that provides an elegant solut ### Key Benefits -- **🚀 Eliminates Queueable Limits**: Automatically handles "Too many queueable jobs" by intelligent chaining and batch overflow -- **🎯 Unified API**: Single, consistent interface for all async job types (Queueable, Batchable, Schedulable) -- **⚡ Smart Prioritization**: Jobs execute based on priority with automatic sorting -- **🛡️ Advanced Error Handling**: Built-in error recovery, rollback options, and continuation strategies -- **📊 Job Tracking**: Comprehensive tracking with custom job IDs and result records -- **⚙️ Configuration-Driven**: Control job behavior through custom metadata without code changes -- **🔗 Support Finalizers**: Execute cleanup logic after job completion with full context +- **🚀 Eliminates Queueable Limits**: Automatically handles "Too many queueable + jobs" by intelligent chaining and batch overflow +- **🎯 Unified API**: Single, consistent interface for all async job types + (Queueable, Batchable, Schedulable) +- **⚡ Smart Prioritization**: Jobs execute based on priority with automatic + sorting +- **🛡️ Advanced Error Handling**: Built-in error recovery, rollback options, and + continuation strategies +- **📊 Job Tracking**: Comprehensive tracking with custom job IDs and result + records +- **⚙️ Configuration-Driven**: Control job behavior through custom metadata + without code changes +- **🔗 Support Finalizers**: Execute cleanup logic after job completion with + full context ## Core Concepts @@ -65,10 +75,12 @@ All queueable jobs extend the `QueueableJob` abstract class: ```apex public class MyQueueableJob extends QueueableJob { - public override void work() { - // Your business logic here - System.debug('Processing job: ' + Async.getQueueableJobContext().currentJob.customJobId); - } + public override void work() { + // Your business logic here + System.debug( + 'Processing job: ' + Async.getQueueableJobContext().currentJob.customJobId + ); + } } ``` @@ -98,7 +110,9 @@ Async.schedulable(new MySchedulableJob()) ### 3. Automatic Job Chaining -When queueable limits are reached, Async Lib automatically switches to scheduled-batch-based execution, ensuring your jobs always run without hitting Queueable platform limits. +When queueable limits are reached, Async Lib automatically switches to +scheduled-batch-based execution, ensuring your jobs always run without hitting +Queueable platform limits. ## Your First Queueable Job @@ -108,26 +122,30 @@ Let's create a simple job that processes accounts: ```apex public class AccountProcessorJob extends QueueableJob { - private List accountIds; - - public AccountProcessorJob(List accountIds) { - this.accountIds = accountIds; - } - - public override void work() { - // Get job context - Async.QueueableJobContext ctx = Async.getQueueableJobContext(); - System.debug('Processing job: ' + ctx.currentJob.customJobId); - - // Process accounts - List accounts = [SELECT Id, Name FROM Account WHERE Id IN :accountIds]; - for (Account acc : accounts) { - acc.Description = 'Processed by ' + ctx.currentJob.className; - } - update accounts; - - System.debug('Processed ' + accounts.size() + ' accounts'); + private List accountIds; + + public AccountProcessorJob(List accountIds) { + this.accountIds = accountIds; + } + + public override void work() { + // Get job context + Async.QueueableJobContext ctx = Async.getQueueableJobContext(); + System.debug('Processing job: ' + ctx.currentJob.customJobId); + + // Process accounts + List accounts = [ + SELECT Id, Name + FROM Account + WHERE Id IN :accountIds + ]; + for (Account acc : accounts) { + acc.Description = 'Processed by ' + ctx.currentJob.className; } + update accounts; + + System.debug('Processed ' + accounts.size() + ' accounts'); + } } ``` @@ -169,7 +187,7 @@ public class MyJobFinalizer extends QueueableJob.Finalizer { public override void work() { Async.QueueableJobContext ctx = Async.getQueueableJobContext(); FinalizerContext finalizerCtx = ctx.finalizerCtx; - + if (finalizerCtx.getResult() == ParentJobResult.SUCCESS) { System.debug('Job completed successfully!'); } else { @@ -182,7 +200,7 @@ public class MyJobFinalizer extends QueueableJob.Finalizer { public class MyMainJob extends QueueableJob { public override void work() { // Do main work... - + // Attach finalizer Async.queueable(new MyJobFinalizer()) .attachFinalizer(); @@ -194,43 +212,54 @@ public class MyMainJob extends QueueableJob { Control job behavior using Custom Metadata (`QueueableJobSetting__mdt`): -1. Go to **Setup → Custom Metadata Types → QueueableJobSetting → Manage Records** +1. Go to **Setup → Custom Metadata Types → QueueableJobSetting → Manage + Records** 2. Create or edit settings: - **All**: Global settings for all jobs - **Specific Class Name**: Settings for specific job classes Available settings: -- **QueueableJobName__c**: The name of the QueueableJob. -- **IsDisabled__c**: Disable job execution -- **CreateResult__c**: Create `AsyncResult__c` records for tracking + +- **QueueableJobName\_\_c**: The name of the QueueableJob. +- **IsDisabled\_\_c**: Disable job execution +- **CreateResult\_\_c**: Create `AsyncResult__c` records for tracking ## 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. +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. ### Key Fields -- **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) - +- **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) ## What's Next? Now that you understand the basics: 1. **Explore the API** - Learn about all available methods and options: - 1. **[Queueable API](/api/queueable.md)** - Detailed information on using Queueable jobs - 2. **[Batchable API](/api/batchable.md)** - Detailed information on using Batchable jobs - 3. **[Schedulable API](/api/schedulable.md)** - Detailed information on using Schedulable jobs -2. **Read the Blog Post** - Check out the detailed explanation: [Apex Queueable Processing Framework](https://blog.beyondthecloud.dev/blog/apex-queueable-processing-framework) -3. **[Initial Scheduled Queueable Batch Job Explanation](/explanations/initial-scheduled-queuable-batch-job.md)** - Learn why this job is important for framework to function properly. + 1. **[Queueable API](/api/queueable.md)** - Detailed information on using + Queueable jobs + 2. **[Batchable API](/api/batchable.md)** - Detailed information on using + Batchable jobs + 3. **[Schedulable API](/api/schedulable.md)** - Detailed information on using + Schedulable jobs +2. **Read the Blog Post** - Check out the detailed explanation: + [Apex Queueable Processing Framework](https://blog.beyondthecloud.dev/blog/apex-queueable-processing-framework) +3. **[Initial Queueable Chain Schedulable Explanation](/explanations/initial-scheduled-queuable-batch-job.md)** - + Learn why this job is important for framework to function properly. ## Quick Tips -- **Job Naming**: Jobs get unique names with timestamps: `MyJob::2024-01-15T10:30:45.123Z::1` -- **Custom Job IDs**: Every job gets a UUID for tracking independent of Salesforce Job IDs -- **Priority Matters**: Lower numbers = higher priority. Finalizers always run first. +- **Job Naming**: Jobs get unique names with timestamps: + `MyJob::2024-01-15T10:30:45.123Z::1` +- **Custom Job IDs**: Every job gets a UUID for tracking independent of + Salesforce Job IDs +- **Priority Matters**: Lower numbers = higher priority. Finalizers always run + first. - **Test Friendly**: Framework handles test context automatically -- **Callouts Supported**: Use `QueueableJob.AllowsCallouts` for HTTP callouts \ No newline at end of file +- **Callouts Supported**: Use `QueueableJob.AllowsCallouts` for HTTP callouts