This is an automated email from the ASF dual-hosted git repository.
pvillard31 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 337069a4eb2 NIFI-16088 Add scheduling diagnostics for cron
primary-node-only processors (#11407)
337069a4eb2 is described below
commit 337069a4eb2f57fc194855df42b16d0e8586f474
Author: Kevin Doran <[email protected]>
AuthorDate: Wed Jul 8 06:40:12 2026 -0400
NIFI-16088 Add scheduling diagnostics for cron primary-node-only processors
(#11407)
Cron triggers are scheduled as one-shot tasks that reschedule only after a
successful run. If the triggered task throws, or the next trigger fails to
be
scheduled, the scheduling chain silently terminates: the component stays
RUNNING but never fires again on its schedule until it is stopped and
started,
with no record in the logs. Make that observable:
- CronSchedulingAgent: log a WARN (before propagating) when a cron-scheduled
trigger throws or when rescheduling the next trigger fails, noting the
component will not fire again until stopped and started.
- ConnectableTask: log at INFO (was DEBUG) when a cron-driven,
primary-node-only
component is skipped because this is not the primary node, so a skipped
scheduled fire is visible; timer-driven components stay at DEBUG to avoid
noise.
Diagnostics only; no scheduling or lifecycle behavior is changed.
---
.../controller/scheduling/CronSchedulingAgent.java | 23 ++++++++++++--
.../nifi/controller/tasks/ConnectableTask.java | 10 +++++-
.../scheduling/CronSchedulingAgentTest.java | 36 ++++++++++++++++++++++
3 files changed, 65 insertions(+), 4 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 f44f1b813ce..17eae44f8c7 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
@@ -129,8 +129,17 @@ public class CronSchedulingAgent extends
AbstractTimeBasedSchedulingAgent {
try {
continuallyRunTask.invoke();
} catch (final RuntimeException re) {
+ // Diagnostic: an exception here skips the
rescheduling below. Because each cron trigger is a
+ // one-shot scheduled task that reschedules only after
a successful run, a throwable silently
+ // terminates the scheduling chain: the component
stays RUNNING but never fires again on its
+ // schedule until it is stopped and started. Log it
before propagating, since the executor
+ // otherwise discards it with no record.
+ logger.warn("Cron-scheduled trigger for {} threw an
exception; its scheduling chain will terminate "
+ + "and it will not run again on its schedule until
it is stopped and started", connectable, re);
throw re;
} catch (final Exception e) {
+ logger.warn("Cron-scheduled trigger for {} threw an
exception; its scheduling chain will terminate "
+ + "and it will not run again on its schedule until
it is stopped and started", connectable, e);
throw new ProcessException(e);
}
@@ -138,9 +147,17 @@ public class CronSchedulingAgent extends
AbstractTimeBasedSchedulingAgent {
final long delay = getDelay(nextSchedule);
logger.debug("Finished task for {}; next scheduled time is
at {} after a delay of {} milliseconds", connectable, nextSchedule, delay);
- final ScheduledFuture<?> newFuture =
flowEngine.schedule(this, delay, TimeUnit.MILLISECONDS);
- final ScheduledFuture<?> oldFuture =
componentFuturesMap.put(taskNumber.get(), newFuture);
- scheduleState.replaceFuture(oldFuture, newFuture);
+ try {
+ final ScheduledFuture<?> newFuture =
flowEngine.schedule(this, delay, TimeUnit.MILLISECONDS);
+ final ScheduledFuture<?> oldFuture =
componentFuturesMap.put(taskNumber.get(), newFuture);
+ scheduleState.replaceFuture(oldFuture, newFuture);
+ } catch (final RuntimeException re) {
+ // Diagnostic: failing to schedule the next trigger
(e.g. the engine is shutting down) also
+ // silently terminates the scheduling chain, leaving
the component RUNNING but unscheduled.
+ logger.warn("Failed to reschedule the next cron
trigger for {}; its scheduling chain will terminate "
+ + "and it will not run again on its schedule until
it is stopped and started", connectable, re);
+ throw re;
+ }
}
};
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/tasks/ConnectableTask.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/tasks/ConnectableTask.java
index 214c1c94fa0..204da4a1cf1 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/tasks/ConnectableTask.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/tasks/ConnectableTask.java
@@ -49,6 +49,7 @@ import org.apache.nifi.processor.SimpleProcessLogger;
import org.apache.nifi.processor.StandardProcessContext;
import org.apache.nifi.processor.exception.ProcessException;
import org.apache.nifi.processor.exception.TerminatedTaskException;
+import org.apache.nifi.scheduling.SchedulingStrategy;
import org.apache.nifi.util.Connectables;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -177,7 +178,14 @@ public class ConnectableTask {
// make sure that either we're not clustered or this processor runs on
all nodes or that this is the primary node
if (!isRunOnCluster(flowController)) {
- logger.debug("Will not trigger {} because this is not the primary
node", connectable);
+ // Diagnostic: a cron-driven primary-node-only component triggers
comparatively rarely, so log at INFO to
+ // make a skipped scheduled fire visible (e.g. a component that
stops firing on its schedule after a
+ // primary-node change). Timer-driven components trigger
frequently, so keep those at DEBUG to avoid noise.
+ if (connectable.getSchedulingStrategy() ==
SchedulingStrategy.CRON_DRIVEN) {
+ logger.info("Will not trigger {} because this is not the
primary node; the cron-scheduled fire is being skipped", connectable);
+ } else {
+ logger.debug("Will not trigger {} because this is not the
primary node", connectable);
+ }
return InvocationResult.yield("This node is not the primary node");
}
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 3b46865ae51..10a61abd38a 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
@@ -27,14 +27,20 @@ import org.apache.nifi.reporting.ReportingTask;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -93,6 +99,36 @@ class CronSchedulingAgentTest {
schedulingAgent.doSchedule(connectable, lifecycleState);
}
+ @Test
+ void testCronTaskExceptionPropagatesRatherThanSilentlyStoppingChain() {
+ // A cron trigger is a one-shot task that reschedules only after a
successful run. If the triggered task
+ // throws, the exception must still propagate (it is also logged as a
diagnostic) rather than being silently
+ // swallowed in a way that could leave the component RUNNING but
unscheduled. This guards that propagation.
+ final String componentId = UUID.randomUUID().toString();
+ final LifecycleState lifecycleState = new LifecycleState(componentId);
+
+
when(connectable.evaluateParameters(eq(DEFAULT_CRON_EXPRESSION))).thenReturn(DEFAULT_CRON_EXPRESSION);
+
when(connectable.getSchedulingPeriod()).thenReturn(DEFAULT_CRON_EXPRESSION);
+ when(connectable.getMaxConcurrentTasks()).thenReturn(CONCURRENT_TASKS);
+ when(connectable.getIdentifier()).thenReturn(componentId);
+
when(flowController.getStateManagerProvider()).thenReturn(stateManagerProvider);
+
when(stateManagerProvider.getStateManager(eq(componentId))).thenReturn(stateManager);
+
when(flowController.getGarbageCollectionLog()).thenReturn(mock(GarbageCollectionLog.class));
+
+ schedulingAgent.doSchedule(connectable, lifecycleState);
+
+ // Capture the self-rescheduling command submitted to the engine and
run it with a triggered task that throws.
+ final ArgumentCaptor<Runnable> commandCaptor =
ArgumentCaptor.forClass(Runnable.class);
+ verify(flowEngine).schedule(commandCaptor.capture(), anyLong(),
any(TimeUnit.class));
+ final Runnable command = commandCaptor.getValue();
+
+ final RuntimeException failure = new RuntimeException("trigger
failure");
+ when(connectable.getYieldExpiration()).thenThrow(failure);
+
+ final RuntimeException thrown = assertThrows(RuntimeException.class,
command::run);
+ assertEquals(failure, thrown);
+ }
+
@Test
void testDoScheduleReportingTaskNode() {
final String componentId = UUID.randomUUID().toString();