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: 1. EXECUTING and no recognized pattern for a recurring job: Returns null 2. 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) 3. 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: ```java 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: ```java @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'); } } ```