I decided to implement a CronHandler that can be constructed from either a cron id, a scheduled job name or a list of configs that allows me to calculate a Last Execution Time based on a couple of premises.
- For inits with a trigger id (i.e. from SchedulableContext), only one CronTrigger record is initialized
- For inits with a list of CronTrigger records or a Job Name (best used with wildcards), all matching records are initialized
The API DateTime getLastExection()
iterates all configs in the handler and returns the latest DateTime value there is. A cron config object can handle the following states:
- EXECUTING and no recognized pattern for a recurring job: Returns null
- EXEUCTING and recognized pattern for a recurring job: Returns a calculated previous fire Time based on distance to the next fire time (with a small 10% overlap)
- NOT EXECUTING (i.e. WAITING, etc): Returns the PreviousFireTime from CronTrigger object. May be null, if the trigger never ran.
This is enough to:
- Evaluate an actual previous fire time directly from SOQL for a series of crons, as long as I have a similar naming schema ('Job #1', 'Job #2', etc). Does not require the crons to be scheduled symmetrical (e.g. 0,15,30,45)
- Estimate the last fire time for a single cron as long as the cron is scheduled to run regularily ('0 0 * * * ?', ...). This works for hourly and daily scheduled jobs equally.
I would be happy to hear your feedback and suggestions to improve the code.
Here's the class:
public class CronHandler {
@testVisible private List<CronConfiguration> CronConfigs;
public CronHandler(Id cronTriggerId) {
this([SELECT Id,StartTime,EndTime,PreviousFireTime,NextFireTime,TimesTriggered,CronExpression,State,CronJobDetail.Name FROM CronTrigger WHERE Id = :cronTriggerId]);
}
public CronHandler(String jobName) {
this([SELECT Id,StartTime,EndTime,PreviousFireTime,NextFireTime,TimesTriggered,CronExpression,State,CronJobDetail.Name FROM CronTrigger WHERE CronJobDetail.Name LIKE :jobName]);
}
public CronHandler(List<CronTrigger> triggerConfigs) {
CronConfigs = new List<CronHandler.CronConfiguration>();
for (CronTrigger ct : triggerConfigs) CronConfigs.add(new CronConfiguration(ct));
}
// DESCRIPTION
// simply forwards the size of all internal configs that are known
public Integer getCronConfigSize() {
return CronConfigs.size();
}
// DESCRIPTION
// Returns the highest value for previous fire time for all configs that are known to the handler
// The configs itself dynamically evaluate if they are to a executing or a waiting cron job
// And return their actual previous fire time or a calculated estimator
// RETURN
// DateTime: The highest previous fire time of all configs
public DateTime getLastExecution() {
List<DateTime> ExecutionDateTimes = new List<DateTime>();
for (CronHandler.CronConfiguration cc : CronConfigs) ExecutionDateTimes.add(cc.getPreviousFireTime());
ExecutionDateTimes.sort();
return ExecutionDateTimes.isEmpty() ? null : ExecutionDateTimes.get(ExecutionDateTimes.size() - 1);
}
public class CronConfiguration {
public String CronExpression;
public String State;
public String CronDetailName;
public DateTime PreviousFireTime;
public DateTime NextFireTime;
public Integer TimesTriggered;
// blank constructor to emulate a cron config for testing
public CronConfiguration() { }
// load configuration from a CronTrigger record
public CronConfiguration(CronTrigger ct) {
CronExpression = ct.CronExpression;
State = ct.State;
CronDetailName = ct.CronJobDetail.Name;
PreviousFireTime = ct.PreviousFireTime;
NextFireTime = ct.NextFireTime;
TimesTriggered = ct.TimesTriggered;
}
// DESCRIPTION
// Returns the best estimator for this CronJob's last execution. If trigger is not executing, returns the actual PreviousFireTime.
// Otherwise, calculates the estimated previous fire time based on cron expression and next fire time.
// RETURN
// DateTime: Best guessed previous fire time for the cron config
public DateTime getPreviousFireTime() {
if (State != 'EXECUTING') return PreviousFireTime;
if (matchesRecurringJobExpression(CronExpression)) return DateTime.newInstance(PreviousFireTime.getTime() - getTimeDifference(NextFireTime, PreviousFireTime));
return null;
}
// DESCRIPTION
// Calculates the difference in milliseconds between two dateTimes.
// The difference is then multiplied by 10%, to account for inaccuracies
// PARAMETERS
// DateTime dtNext: expected to be the higher of both values
// DateTime dtPrev: expected to be the lower of both values
// RETURN
// Long: Difference between both datetime values with 10% inaccuracy
private Long getTimeDifference(DateTime dtNext, DateTime dtPrev) {
return ((dtNext.getTime() - dtPrev.getTime())*1.1).longValue();
}
// DESCRIPTION
// Splits the cron expression in its part and validates each list-element individually
// Only validates elements where wildards may appear (hour and up) and exists with false
// RETURN
// Boolean: Evaluation, if expression represents a regular recurring job
private Boolean matchesRecurringJobExpression(String cronExp) {
List<String> splitExpression = cronExp.split(' ');
// hours (3rd element): fix numbers or recurring is accepted
if (!Pattern.matches('[0-9]{1,2}|\\*', splitExpression.get(2))) return false;
// days of month (4th element): only recurring / arbitrary is accepted
if (!Pattern.matches('\\*|\\?', splitExpression.get(3))) return false;
// month (5th element): only recurring is accepted
if (!Pattern.matches('\\*', splitExpression.get(4))) return false;
// day of week (6th element): only recurring / arbitrary is accepted
if (!Pattern.matches('\\*|\\?', splitExpression.get(5))) return false;
return true;
}
}
}
And here are some tests:
@isTest
public class T_CronHandler {
@isTest
static void initHandlerByName_NoCronConfigurationsExisting_NoConfigsLoaded() {
CronHandler ch = new CronHandler('Unknown Test Config');
System.assertEquals(0, ch.getCronConfigSize(), 'Configs have been initiated, but expecting 0');
}
@isTest
static void initHandlerById_NoCronConfigurationsExisting_NoConfigsLoaded() {
// SETUP
// this is a syntactically valid cron trigger id that should never exist
Id invalidId = '08e4E00000XXXXXXXX';
CronHandler ch = new CronHandler(invalidId);
// VERIFY
System.assertEquals(0, ch.getCronConfigSize(), 'Configs have been initiated, but expecting 0');
}
@isTest
static void initHandlerByName_SingleCronConfigurationExisting_OneConfigLoaded() {
// SETUP
// schedule the Dispatcher_ScheduledSends
System.schedule('Test Send #1', '0 0 * * * ?', new Dispatcher_ScheduledSends());
CronHandler ch = new CronHandler('Test Send%');
// VERIFY
System.assertEquals(1, ch.getCronConfigSize(), 'Wrong number of cron configs initiated');
}
@isTest
static void initHandlerByName_MultipleCronConfigurationExisting_AllConfigsLoaded() {
// SETUP
// schedule the Dispatcher_ScheduledSends
System.schedule('Test Send #1', '0 0 * * * ?', new Dispatcher_ScheduledSends());
System.schedule('Test Send #3', '0 15 * * * ?', new Dispatcher_ScheduledSends());
System.schedule('Test Send #2', '0 30 * * * ?', new Dispatcher_ScheduledSends());
System.schedule('Test Send #4', '0 45 * * * ?', new Dispatcher_ScheduledSends());
CronHandler ch = new CronHandler('Test Send%');
// VERIFY
System.assertEquals(4, ch.getCronConfigSize(), 'Wrong number of cron configs initiated');
}
@isTest
static void initHandlerByName_MultipleCronConfigurationOnDifferentNamesExisting_OnlyMatchingConfigsLoaded() {
// SETUP
// schedule the Dispatcher_ScheduledSends
System.schedule('First Send #1', '0 0 * * * ?', new Dispatcher_ScheduledSends());
System.schedule('First Send #3', '0 15 * * * ?', new Dispatcher_ScheduledSends());
System.schedule('Another Send #1', '0 30 * * * ?', new Dispatcher_ScheduledSends());
System.schedule('Another Send #2', '0 45 * * * ?', new Dispatcher_ScheduledSends());
CronHandler ch = new CronHandler('First Send%');
// VERIFY
System.assertEquals(2, ch.getCronConfigSize(), 'Wrong number of cron configs initiated');
}
@isTest
static void getLastExecution_NoConfigLoaded_Null() {
CronHandler ch = new CronHandler('Unknown Test Config');
System.assertEquals(null, ch.getLastExecution(), 'Wrong value for last execution');
}
@isTest
static void getLastExecution_ExecutingCronInitialized_PrevFromExecuting() {
CronHandler ch = new CronHandler('Unknown Test Config');
// ACTION
CronHandler.CronConfiguration cc = new CronHandler.CronConfiguration();
cc.State = 'EXECUTING';
cc.CronExpression = '0 0 * * * ?';
cc.PreviousFireTime = DateTime.newInstance(2019, 05, 06, 07, 23, 57);
cc.NextFireTime = DateTime.newInstance(2019, 05, 06, 08, 23, 55);
ch.CronConfigs.add(cc);
// VERIFY
System.assertNotEquals(null, ch.getLastExecution(), 'Last execution is null');
System.assertEquals(cc.getPreviousFireTime(), ch.getLastExecution(), 'Wrong value for last execution');
}
@isTest
static void getLastExecution_MultipleHourlyCrons_WaitingHasExecutions_PrevFromWaiting() {
CronHandler ch = new CronHandler('Unknown Test Config');
// ACTION
// setup config for executing job
CronHandler.CronConfiguration cc1 = new CronHandler.CronConfiguration();
cc1.State = 'EXECUTING';
cc1.CronExpression = '0 0 * * * ?';
cc1.PreviousFireTime = DateTime.newInstance(2019, 05, 06, 07, 00, 2);
cc1.NextFireTime = DateTime.newInstance(2019, 05, 06, 08, 00, 00);
ch.CronConfigs.add(cc1);
// setup config for waiting job
CronHandler.CronConfiguration cc2 = new CronHandler.CronConfiguration();
cc2.State = 'WAITING';
cc2.CronExpression = '0 30 * * * ?';
cc2.PreviousFireTime = DateTime.newInstance(2019, 05, 06, 06, 30, 05);
cc2.NextFireTime = DateTime.newInstance(2019, 05, 06, 07, 30, 00);
ch.CronConfigs.add(cc2);
// VERIFY
System.assertNotEquals(null, ch.getLastExecution(), 'Last execution is null');
System.assertEquals(cc2.getPreviousFireTime(), ch.getLastExecution(), 'Wrong value for last execution');
}
@isTest
static void getLastExecution_MixedHourlyAndDailyCrons_WaitingHasExecutions_PrevFromWaiting() {
CronHandler ch = new CronHandler('Unknown Test Config');
// ACTION
// setup config for executing job
CronHandler.CronConfiguration cc1 = new CronHandler.CronConfiguration();
cc1.State = 'EXECUTING';
cc1.CronExpression = '0 0 * * * ?';
cc1.PreviousFireTime = DateTime.newInstance(2019, 05, 06, 07, 00, 2);
cc1.NextFireTime = DateTime.newInstance(2019, 05, 06, 08, 00, 00);
ch.CronConfigs.add(cc1);
// setup config for waiting job
CronHandler.CronConfiguration cc2 = new CronHandler.CronConfiguration();
cc2.State = 'WAITING';
cc2.CronExpression = '0 30 23 * * ?';
cc2.PreviousFireTime = DateTime.newInstance(2019, 05, 05, 23, 30, 05);
cc2.NextFireTime = DateTime.newInstance(2019, 05, 06, 23, 30, 00);
ch.CronConfigs.add(cc2);
// VERIFY
System.assertNotEquals(null, ch.getLastExecution(), 'Last execution is null');
System.assertEquals(cc1.getPreviousFireTime(), ch.getLastExecution(), 'Wrong value for last execution');
}
@isTest
static void getLastExecution_WaitingHourlyCrons_NoExecutions_Null() {
CronHandler ch = new CronHandler('Unknown Test Config');
// ACTION
// setup config for executing job
CronHandler.CronConfiguration cc1 = new CronHandler.CronConfiguration();
cc1.State = 'WAITING';
cc1.CronExpression = '0 0 * * * ?';
cc1.NextFireTime = DateTime.newInstance(2019, 05, 07, 00, 00, 00);
ch.CronConfigs.add(cc1);
// setup config for waiting job
CronHandler.CronConfiguration cc2 = new CronHandler.CronConfiguration();
cc2.State = 'WAITING';
cc2.CronExpression = '0 30 * * * ?';
cc2.NextFireTime = DateTime.newInstance(2019, 05, 07, 0, 30, 00);
ch.CronConfigs.add(cc2);
// VERIFY
System.assertEquals(null, ch.getLastExecution(), 'Last execution is null');
}
@isTest
static void cronConfig_getPreviousFireTime_NotExecuting_InternalValueReturned() {
// SETUP
// emulate a cron config for a waiting trigger
CronHandler.CronConfiguration cc = new CronHandler.CronConfiguration();
cc.PreviousFireTime = DateTime.newInstance(2019, 05, 06, 07, 23, 57);
cc.NextFireTime = DateTime.newInstance(2019, 05, 06, 08, 23, 55);
// ACTION & VERIFY
cc.State = 'WAITING';
System.assertEquals(DateTime.newInstance(2019, 05, 06, 07, 23, 57), cc.getPreviousFireTime(), 'Wrong previous fire time returned for WAITING');
cc.State = 'PAUSED';
System.assertEquals(DateTime.newInstance(2019, 05, 06, 07, 23, 57), cc.getPreviousFireTime(), 'Wrong previous fire time returned for PAUSED');
cc.State = 'ACQUIRED';
System.assertEquals(DateTime.newInstance(2019, 05, 06, 07, 23, 57), cc.getPreviousFireTime(), 'Wrong previous fire time returned for ACQUIRED');
cc.State = 'COMPLETE';
System.assertEquals(DateTime.newInstance(2019, 05, 06, 07, 23, 57), cc.getPreviousFireTime(), 'Wrong previous fire time returned for COMPLETED');
cc.State = 'ERROR';
System.assertEquals(DateTime.newInstance(2019, 05, 06, 07, 23, 57), cc.getPreviousFireTime(), 'Wrong previous fire time returned for ERROR');
cc.State = 'BLOCKED';
System.assertEquals(DateTime.newInstance(2019, 05, 06, 07, 23, 57), cc.getPreviousFireTime(), 'Wrong previous fire time returned for BLOCKED');
cc.State = 'PAUSED_BLOCKED';
System.assertEquals(DateTime.newInstance(2019, 05, 06, 07, 23, 57), cc.getPreviousFireTime(), 'Wrong previous fire time returned for PAUSED_BLOCKED');
}
@isTest
static void cronConfig_getPreviousFireTime_NotExecutingNoRuns_RecurringCronExpression_InternalValueReturned() {
// SETUP
// emulate a cron config for a waiting trigger
CronHandler.CronConfiguration cc = new CronHandler.CronConfiguration();
cc.State = 'WAITING';
cc.CronExpression = '0 30 * * * ?';
cc.NextFireTime = DateTime.newInstance(2019, 05, 06, 08, 30, 00);
// ACTION & VERIFY
System.assertEquals(null, cc.getPreviousFireTime(), 'Wrong value for WAITING');
}
@isTest
static void cronConfig_getPreviousFireTime_Executing_RecurringCronExpression_CalculatedPrevTime() {
// SETUP
// emulate a cron config for a waiting trigger
CronHandler.CronConfiguration cc = new CronHandler.CronConfiguration();
cc.State = 'EXECUTING';
cc.PreviousFireTime = DateTime.newInstance(2019, 05, 06, 07, 23, 57);
cc.NextFireTime = DateTime.newInstance(2019, 05, 06, 08, 23, 55);
// ACTION & VERIFY
// calculate a DateTime with 10% bonus in difference
DateTime expectedVal = DateTime.newInstance(cc.PreviousFireTime.getTime() - ((cc.NextFireTime.getTime() - cc.PreviousFireTime.getTime())*1.1).longValue());
cc.CronExpression = '0 0 * * * ?';
System.assertEquals(expectedVal, cc.getPreviousFireTime(), 'Wrong value for: 0 0 * * * ?');
cc.CronExpression = '0 0 * * * *';
System.assertEquals(expectedVal, cc.getPreviousFireTime(), 'Wrong value for: 0 0 * * * *');
cc.CronExpression = '12 33 * * * ?';
System.assertEquals(expectedVal, cc.getPreviousFireTime(), 'Wrong value for: 12 33 * * * ?');
cc.CronExpression = '0 0 5 * * ?';
System.assertEquals(expectedVal, cc.getPreviousFireTime(), 'Wrong value for: 0 0 5 * * ?');
cc.CronExpression = '59 59 23 ? * * *';
System.assertEquals(expectedVal, cc.getPreviousFireTime(), 'Wrong value for: 59 59 23 ? * *');
}
@isTest
static void cronConfig_getPreviousFireTime_Executing_NonrecurringCronExpression_Null() {
// SETUP
// emulate a cron config for a waiting trigger
CronHandler.CronConfiguration cc = new CronHandler.CronConfiguration();
cc.State = 'EXECUTING';
cc.PreviousFireTime = DateTime.newInstance(2019, 05, 06, 07, 23, 57);
cc.NextFireTime = DateTime.newInstance(2019, 05, 06, 08, 23, 55);
// ACTION & VERIFY
cc.CronExpression = '0 0 * 1/5 * ?';
System.assertEquals(null, cc.getPreviousFireTime(), 'Wrong value for: 0 0 * 1/5 * ?');
cc.CronExpression = '12 33 22 ? * MON-FRI';
System.assertEquals(null, cc.getPreviousFireTime(), 'Wrong value for: 12 33 22 ? * MON-FRI');
}
}
finish()
method (or evenstart()
), create a new log record with an Id to the previous run, as well as run times.