From 4eab390f2b8ef8110018699beaa97423b891ddc6 Mon Sep 17 00:00:00 2001 From: dan-s1 Date: Tue, 27 Oct 2026 22:24:30 +0000 Subject: [PATCH 1/2] NIFI-16515 Corrected cron scheduling to only execute once after Daylight Saving Time transition. --- .../scheduling/CronSchedulingAgent.java | 31 ++++++++++++------- .../scheduling/CronSchedulingAgentTest.java | 21 +++++++++++++ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/scheduling/CronSchedulingAgent.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/scheduling/CronSchedulingAgent.java index 17eae44f8c77..a78b56f03869 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/scheduling/CronSchedulingAgent.java +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/scheduling/CronSchedulingAgent.java @@ -25,7 +25,7 @@ import org.apache.nifi.processor.exception.ProcessException; import org.springframework.scheduling.support.CronExpression; -import java.time.OffsetDateTime; +import java.time.ZonedDateTime; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ScheduledFuture; @@ -67,12 +67,12 @@ public void doSchedule(final ReportingTaskNode taskNode, final LifecycleState sc final ReportingTaskWrapper taskWrapper = new ReportingTaskWrapper(taskNode, scheduleState, flowController.getExtensionManager()); - final OffsetDateTime initialDate = getInitialDate(cronExpression); + final ZonedDateTime initialDate = getInitialDate(cronExpression); final long initialDelay = getInitialDelay(initialDate); final Runnable command = new Runnable() { - private OffsetDateTime nextSchedule = initialDate; + private ZonedDateTime nextSchedule = initialDate; @Override public void run() { @@ -117,12 +117,12 @@ public synchronized void doSchedule(final Connectable connectable, final Lifecyc final AtomicInteger taskNumber = new AtomicInteger(i); - final OffsetDateTime initialDate = getInitialDate(cronExpression); + final ZonedDateTime initialDate = getInitialDate(cronExpression); final long initialDelay = getInitialDelay(initialDate); final Runnable command = new Runnable() { - private OffsetDateTime nextSchedule = initialDate; + private ZonedDateTime nextSchedule = initialDate; @Override public void run() { @@ -200,25 +200,32 @@ public void onEvent(final Connectable connectable) { public void setMaxThreadCount(final int maxThreads) { } - private OffsetDateTime getInitialDate(final CronExpression cronExpression) { - final OffsetDateTime now = OffsetDateTime.now(); - final OffsetDateTime initialDate = cronExpression.next(now); + private ZonedDateTime getInitialDate(final CronExpression cronExpression) { + final ZonedDateTime now = ZonedDateTime.now(); + final ZonedDateTime initialDate = cronExpression.next(now); return initialDate == null ? now : initialDate; } - private long getInitialDelay(final OffsetDateTime initialDate) { + private long getInitialDelay(final ZonedDateTime initialDate) { return initialDate.toInstant().toEpochMilli() - System.currentTimeMillis(); } - private static OffsetDateTime getNextSchedule(final OffsetDateTime currentSchedule, final CronExpression cronExpression) { + private static ZonedDateTime getNextSchedule(final ZonedDateTime currentSchedule, final CronExpression cronExpression) { // Since the clock has not a millisecond precision, we have to check that we // schedule the next time after the time this was supposed to run, otherwise // we might end up with running the same task twice - final OffsetDateTime now = OffsetDateTime.now(); + final ZonedDateTime now = ZonedDateTime.now(currentSchedule.getZone()); + return getNextSchedule(currentSchedule, cronExpression, now); + } + + static ZonedDateTime getNextSchedule( + final ZonedDateTime currentSchedule, + final CronExpression cronExpression, + final ZonedDateTime now) { return cronExpression.next(now.isAfter(currentSchedule) ? now : currentSchedule); } - private static long getDelay(final OffsetDateTime nextSchedule) { + private static long getDelay(final ZonedDateTime nextSchedule) { return Math.max(nextSchedule.toInstant().toEpochMilli() - System.currentTimeMillis(), 0L); } } diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/scheduling/CronSchedulingAgentTest.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/scheduling/CronSchedulingAgentTest.java index 10a61abd38a5..6b160a26a51c 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/scheduling/CronSchedulingAgentTest.java +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/scheduling/CronSchedulingAgentTest.java @@ -30,7 +30,12 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.scheduling.support.CronExpression; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -150,4 +155,20 @@ void testDoScheduleReportingTaskNodeCronExpressionInvalid() { assertThrows(IllegalStateException.class, () -> schedulingAgent.doSchedule(reportingTaskNode, lifecycleState)); } + + @Test + void testNextScheduleAcrossAutumnDaylightSavingTransition() { + final ZoneId zone = ZoneId.of("Europe/Amsterdam"); + final CronExpression cronExpression = CronExpression.parse("0 30 20 ? * MON-FRI"); + + // Friday before the DST transition. + final ZonedDateTime currentSchedule = ZonedDateTime.of(2026, 10, 23, 20, 30, 0, 0, zone); + + // Supply a fixed time rather than depending on the system clock. + final ZonedDateTime nextSchedule = CronSchedulingAgent.getNextSchedule(currentSchedule, cronExpression, currentSchedule); + + assertEquals(LocalDateTime.of(2026, 10, 26, 20, 30), nextSchedule.toLocalDateTime()); + assertEquals(ZoneOffset.ofHours(1), nextSchedule.getOffset()); + assertEquals(zone, nextSchedule.getZone()); + } } From 8274640e869d480a8b40b083b4c3c6fc38bd845d Mon Sep 17 00:00:00 2001 From: dan-s1 Date: Thu, 29 Oct 2026 16:37:41 +0000 Subject: [PATCH 2/2] NIFI-16515 Review --- .../controller/scheduling/CronSchedulingAgentTest.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/scheduling/CronSchedulingAgentTest.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/scheduling/CronSchedulingAgentTest.java index 6b160a26a51c..54dd9604761f 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/scheduling/CronSchedulingAgentTest.java +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/scheduling/CronSchedulingAgentTest.java @@ -158,16 +158,18 @@ void testDoScheduleReportingTaskNodeCronExpressionInvalid() { @Test void testNextScheduleAcrossAutumnDaylightSavingTransition() { - final ZoneId zone = ZoneId.of("Europe/Amsterdam"); final CronExpression cronExpression = CronExpression.parse("0 30 20 ? * MON-FRI"); // Friday before the DST transition. - final ZonedDateTime currentSchedule = ZonedDateTime.of(2026, 10, 23, 20, 30, 0, 0, zone); + final LocalDateTime currentScheduleTime = LocalDateTime.of(2026, 10, 23, 20, 30); + final ZoneId zone = ZoneId.of("Europe/Amsterdam"); + final ZonedDateTime currentSchedule = currentScheduleTime.atZone(zone); // Supply a fixed time rather than depending on the system clock. final ZonedDateTime nextSchedule = CronSchedulingAgent.getNextSchedule(currentSchedule, cronExpression, currentSchedule); - assertEquals(LocalDateTime.of(2026, 10, 26, 20, 30), nextSchedule.toLocalDateTime()); + final LocalDateTime expectedSchedule = LocalDateTime.of(2026, 10, 26, 20, 30); + assertEquals(expectedSchedule, nextSchedule.toLocalDateTime()); assertEquals(ZoneOffset.ofHours(1), nextSchedule.getOffset()); assertEquals(zone, nextSchedule.getZone()); }