diff --git a/system/async/tasks/ScheduledTask.cfc b/system/async/tasks/ScheduledTask.cfc index c76e80146..582a44193 100644 --- a/system/async/tasks/ScheduledTask.cfc +++ b/system/async/tasks/ScheduledTask.cfc @@ -834,6 +834,13 @@ component accessors="true" { } } + // If this is an interval-based (every()) task with an explicit daily start time and no + // initial delay was set some other way, align the first execution to the next period + // boundary counted from that start time instead of firing immediately on registration. + if ( variables.period > 0 && variables.delay == 0 && len( variables.startTime ) ) { + calculateStartTimeAlignedDelay(); + } + debugLog( "start", { @@ -1550,6 +1557,43 @@ component accessors="true" { variables.timeUnit = "seconds"; } + /** + * When an interval-based task ( every() ) has an explicit daily start time + * ( startOnTime() / between() ) but no initial delay was set some other way, this + * calculates an initial delay that aligns the first execution to the next period + * boundary counted from that start time, instead of firing immediately on registration. + */ + private function calculateStartTimeAlignedDelay(){ + var now = getJavaNow(); + var anchor = now + .withHour( javacast( "int", getToken( variables.startTime, 1, ":" ) ) ) + .withMinute( javacast( "int", getToken( variables.startTime, 2, ":" ) ) ) + .withSecond( javacast( "int", 0 ) ) + .withNano( javacast( "int", 0 ) ); + + var periodSeconds = variables.timeUnitHelper + .get( variables.timeUnit ) + .toSeconds( javacast( "long", variables.period ) ); + + if ( periodSeconds <= 0 ) { + return; + } + + var jDuration = variables.dateTimeHelper.duration().getNative(); + var elapsedSeconds = jDuration.between( anchor, now ).getSeconds(); + if ( elapsedSeconds > 0 ) { + var periodsElapsed = int( elapsedSeconds / periodSeconds ) + 1; + anchor = anchor.plusSeconds( javacast( "long", periodsElapsed * periodSeconds ) ); + } + + // Set delay/period directly (in seconds), matching the smart every*At() helpers + variables.delay = jDuration.between( now, anchor ).getSeconds(); + variables.delayTimeUnit = "seconds"; + variables.period = periodSeconds; + variables.timeUnit = "seconds"; + variables.stats.nextRun = anchor.toString(); + } + /** * This method is called to set the next run time of the task based on the timeUnit and period. */ diff --git a/tests/specs/async/tasks/ScheduledTaskSpec.cfc b/tests/specs/async/tasks/ScheduledTaskSpec.cfc index e486eefe5..656b5eba6 100644 --- a/tests/specs/async/tasks/ScheduledTaskSpec.cfc +++ b/tests/specs/async/tasks/ScheduledTaskSpec.cfc @@ -178,6 +178,32 @@ component extends="tests.specs.async.BaseAsyncSpec" { expect( t.getPeriod() ).toBe( 31536000 ); expect( t.getTimeUnit() ).toBe( "seconds" ); } ); + + it( "can align every() + startOnTime() to the next period boundary instead of firing immediately", function(){ + var t = scheduler + .task( "test" ) + .every( 1800, "seconds" ) + .startOnTime( "00:00" ); + t.start(); + expect( t.getDelay() ).toBeGT( 0 ); + expect( t.getDelay() ).toBeLTE( 1800 ); + } ); + + it( "every() without startOnTime() still fires immediately (unchanged behavior)", function(){ + var t = scheduler.task( "test" ).every( 1800, "seconds" ); + t.start(); + expect( t.getDelay() ).toBe( 0 ); + } ); + + it( "explicit delay() takes precedence over startOnTime() alignment", function(){ + var t = scheduler + .task( "test" ) + .every( 1800, "seconds" ) + .startOnTime( "00:00" ) + .delay( 5, "seconds", true ); + t.start(); + expect( t.getDelay() ).toBe( 5 ); + } ); } ); describe( "can register frequencies with constraints", function(){