From d592af198ea97f0649e275b67ea68ee62beb31b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 17:01:39 +0000 Subject: [PATCH 1/2] fix: align every()+startOnTime()/between() to next period boundary instead of firing immediately A scheduled task built with every(period).startOnTime("HH:mm") (or .between(startTime, endTime)) fired immediately the moment the scheduler registered it (e.g. on every server/container restart) instead of waiting for the next period boundary counted from the daily start time. Root cause: every() only set variables.period/timeUnit, never variables.delay (defaults to 0). startTime/endTime were only consulted by isConstrained() as a runtime gate on later ticks, not fed into the initial delay calculation, so start() handed delay=0 straight to scheduleAtFixedRate(). Fix mirrors the existing "smart" everyDayAt()/setInitialDelayPeriodAndTimeUnit() pattern: start() now computes an initial delay aligned to the next period boundary from startTime when period > 0, delay == 0, and startTime is set. Tasks with an explicit delay() or one-off schedule() are unaffected. This is a behavior change for anyone relying on the old "fire immediately at registration" semantics for every()+startOnTime()/between(). Mirrors the identical fix already merged in ortus-boxlang/BoxLang PR #606 (Jira BL-2633). --- system/async/tasks/ScheduledTask.cfc | 44 +++++++++++++++++++ tests/specs/async/tasks/ScheduledTaskSpec.cfc | 22 ++++++++++ 2 files changed, 66 insertions(+) diff --git a/system/async/tasks/ScheduledTask.cfc b/system/async/tasks/ScheduledTask.cfc index c76e80146..18db90a04 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..dd6207ded 100644 --- a/tests/specs/async/tasks/ScheduledTaskSpec.cfc +++ b/tests/specs/async/tasks/ScheduledTaskSpec.cfc @@ -178,6 +178,28 @@ 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(){ From 3c006e639d18539e1cd53079069c4d11d05760bd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 17:12:16 +0000 Subject: [PATCH 2/2] style: apply cfformat rules to scheduler alignment fix CI's Checks Source Code Formatting job flagged two formatting violations introduced by the previous commit: a misaligned consecutive assignment in calculateStartTimeAlignedDelay(), and method chains in the new tests that needed to wrap per the project's method_call.chain.multiline rule. Verified locally with the project's own cfformat (./.cfformat.json) - only a pre-existing, unrelated violation in system/web/routing/Router.cfc remains, which also fails on development and is out of scope here. --- system/async/tasks/ScheduledTask.cfc | 2 +- tests/specs/async/tasks/ScheduledTaskSpec.cfc | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/system/async/tasks/ScheduledTask.cfc b/system/async/tasks/ScheduledTask.cfc index 18db90a04..582a44193 100644 --- a/system/async/tasks/ScheduledTask.cfc +++ b/system/async/tasks/ScheduledTask.cfc @@ -1583,7 +1583,7 @@ component accessors="true" { var elapsedSeconds = jDuration.between( anchor, now ).getSeconds(); if ( elapsedSeconds > 0 ) { var periodsElapsed = int( elapsedSeconds / periodSeconds ) + 1; - anchor = anchor.plusSeconds( javacast( "long", periodsElapsed * periodSeconds ) ); + anchor = anchor.plusSeconds( javacast( "long", periodsElapsed * periodSeconds ) ); } // Set delay/period directly (in seconds), matching the smart every*At() helpers diff --git a/tests/specs/async/tasks/ScheduledTaskSpec.cfc b/tests/specs/async/tasks/ScheduledTaskSpec.cfc index dd6207ded..656b5eba6 100644 --- a/tests/specs/async/tasks/ScheduledTaskSpec.cfc +++ b/tests/specs/async/tasks/ScheduledTaskSpec.cfc @@ -180,7 +180,10 @@ component extends="tests.specs.async.BaseAsyncSpec" { } ); 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" ); + var t = scheduler + .task( "test" ) + .every( 1800, "seconds" ) + .startOnTime( "00:00" ); t.start(); expect( t.getDelay() ).toBeGT( 0 ); expect( t.getDelay() ).toBeLTE( 1800 ); @@ -193,7 +196,8 @@ component extends="tests.specs.async.BaseAsyncSpec" { } ); it( "explicit delay() takes precedence over startOnTime() alignment", function(){ - var t = scheduler.task( "test" ) + var t = scheduler + .task( "test" ) .every( 1800, "seconds" ) .startOnTime( "00:00" ) .delay( 5, "seconds", true );