This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 52efe7a40b issue #7519 : Kafka Consumer stop when idle (#7524)
52efe7a40b is described below

commit 52efe7a40b7c323345f8fbe325d51a164a8fca5e
Author: Matt Casters <[email protected]>
AuthorDate: Wed Jul 15 13:40:10 2026 +0200

    issue #7519 : Kafka Consumer stop when idle (#7524)
    
    * issue #7519 : Kafka Consumer stop when idle
    
    Add stopWhenIdle and maxIdleTimeMs options so the consumer can drain a
    topic and exit after a configurable idle period (batch-style use cases).
    
    * issue #7519 : fix Kafka IT bootstrap and idle assignment race
    
    Harden BOOTSTRAP_SERVERS for the single-JVM runner, wait for Kafka
    health in docker, ignore idle time until partitions are assigned, and
    convert basic/mapping ITs to stop-when-idle.
---
 .../pages/pipeline/transforms/kafkaconsumer.adoc   |  14 +-
 .../0001-kafka-consumer-read-record-basic.hpl      |  30 +---
 .../0002-kafka-consumer-read-record-mapping.hpl    |  29 +---
 .../0003-kafka-consumer-called-subpipeline.hpl     | 112 ++++++++++++++
 ...=> 0003-kafka-consumer-read-stop-when-idle.hpl} |  36 +----
 .../kafka/main-0003-kafka-test-stop-when-idle.hwf  | 171 +++++++++++++++++++++
 ... => prepare-kafka-test-0003-stop-when-idle.hpl} | 105 +++++--------
 .../kafka/consumer/KafkaConsumerInput.java         |  30 +++-
 .../kafka/consumer/KafkaConsumerInputData.java     |   3 +
 .../kafka/consumer/KafkaConsumerInputDialog.java   |  37 ++++-
 .../kafka/consumer/KafkaConsumerInputMeta.java     |  35 ++++-
 .../consumer/messages/messages_en_US.properties    |   4 +
 .../kafka/consumer/KafkaConsumerInputMetaTest.java |  25 +++
 13 files changed, 472 insertions(+), 159 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/kafkaconsumer.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/kafkaconsumer.adoc
index 28ee201c4a..a388360e51 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/kafkaconsumer.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/kafkaconsumer.adoc
@@ -16,7 +16,7 @@ under the License.
 ////
 :documentationPath: /pipeline/transforms/
 :language: en_US
-:description: The Kafka Consumer transform continuously pulls data from Kafka. 
It runs a sub-pipeline that executes according to message batch size or 
duration.
+:description: The Kafka Consumer transform pulls data from Kafka. It runs a 
sub-pipeline that executes according to message batch size or duration, and can 
optionally stop when idle.
 
 = image:transforms/icons/KafkaConsumerInput.svg[Kafka Consumer transform Icon, 
role="image-doc-icon"] Kafka Consumer
 
@@ -40,9 +40,11 @@ Kafka scales topic consumption by distributing partitions 
among a consumer group
 
 A consumer group is a set of consumers sharing a common group identifier.
 
-Since the Kafka Consumer transform continuously ingests streaming data, you 
may want to use the Abort transform in your parent or sub-pipeline to stop 
consuming records from Kafka for specific workflows.
+By default the Kafka Consumer transform continuously ingests streaming data.
+To stop after the topic has been drained, enable *Stop when idle* on the Batch 
tab (with an optional max idle time).
+You can also use the Abort transform in your parent or sub-pipeline to stop 
consuming records for other conditions.
 
-For example, you can run the parent pipeline on a timed schedule, or abort the 
sub-pipeline if sensor data exceeds a preset range.
+For example, you can run the parent pipeline on a timed schedule, drain a 
topic once per hour with stop-when-idle, or abort the sub-pipeline if sensor 
data exceeds a preset range.
 
 |
 == Supported Engines
@@ -99,6 +101,12 @@ If set to a value of ‘0’, then Number of records triggers 
consumption.
 |Number of records|Specify a number.
 After every ‘X’ number of records, the specified pipeline will be executed and 
these ‘X’ records will be passed to the pipeline.
 If set to a value of ‘0’ then Duration triggers consumption.
+|Stop when idle|When enabled, the consumer stops gracefully after no records 
are received for the configured max idle time.
+This supports batch-style use cases such as draining a topic periodically 
instead of running forever.
+While this option is enabled, the consumer polls with a short (100 ms) timeout 
so idle time can be measured accurately.
+|Max idle time (ms)|The maximum time in milliseconds to wait without receiving 
records before stopping when *Stop when idle* is enabled.
+Defaults to 500.
+Supports variables.
 |Offset management a|Choose when to commit
 
 * when record read
diff --git a/integration-tests/kafka/0001-kafka-consumer-read-record-basic.hpl 
b/integration-tests/kafka/0001-kafka-consumer-read-record-basic.hpl
index f10879691b..c6eac958df 100644
--- a/integration-tests/kafka/0001-kafka-consumer-read-record-basic.hpl
+++ b/integration-tests/kafka/0001-kafka-consumer-read-record-basic.hpl
@@ -46,11 +46,6 @@ limitations under the License.
       <to>Log Output</to>
       <enabled>Y</enabled>
     </hop>
-    <hop>
-      <from>Log Output</from>
-      <to>Stop Kafka Processing</to>
-      <enabled>Y</enabled>
-    </hop>
   </order>
   <transform>
     <name>Kafka Consumer</name>
@@ -67,8 +62,10 @@ limitations under the License.
     <consumerGroup/>
     
<pipelinePath>${PROJECT_HOME}/0001-kafka-consumer-called-subpipeline.hpl</pipelinePath>
     <subTransform>Out record</subTransform>
-    <batchSize>1</batchSize>
+    <batchSize>10</batchSize>
     <batchDuration>1000</batchDuration>
+    <stopWhenIdle>Y</stopWhenIdle>
+    <maxIdleTimeMs>5000</maxIdleTimeMs>
     <directBootstrapServers>${BOOTSTRAP_SERVERS}</directBootstrapServers>
     <AUTO_COMMIT>N</AUTO_COMMIT>
     <OutputField kafkaName="key" type="String">Key</OutputField>
@@ -118,27 +115,6 @@ limitations under the License.
       <yloc>112</yloc>
     </GUI>
   </transform>
-  <transform>
-    <name>Stop Kafka Processing</name>
-    <type>Abort</type>
-    <description/>
-    <distribute>Y</distribute>
-    <custom_distribution/>
-    <copies>1</copies>
-    <partitioning>
-      <method>none</method>
-      <schema_name/>
-    </partitioning>
-    <abort_option>SAFE_STOP</abort_option>
-    <always_log_rows>Y</always_log_rows>
-    <message>Kafka consumer stopped</message>
-    <row_threshold>0</row_threshold>
-    <attributes/>
-    <GUI>
-      <xloc>672</xloc>
-      <yloc>112</yloc>
-    </GUI>
-  </transform>
   <transform_error_handling>
   </transform_error_handling>
   <attributes/>
diff --git 
a/integration-tests/kafka/0002-kafka-consumer-read-record-mapping.hpl 
b/integration-tests/kafka/0002-kafka-consumer-read-record-mapping.hpl
index 93d136cc7d..546080a909 100644
--- a/integration-tests/kafka/0002-kafka-consumer-read-record-mapping.hpl
+++ b/integration-tests/kafka/0002-kafka-consumer-read-record-mapping.hpl
@@ -61,8 +61,10 @@ limitations under the License.
     
<pipelinePath>${PROJECT_HOME}/0002-kafka-consumer-called-subpipeline-with-mapping.hpl</pipelinePath>
     <executionInformationLocation/>
     <executionDataProfile/>
-    <batchSize>1</batchSize>
+    <batchSize>10</batchSize>
     <batchDuration>1000</batchDuration>
+    <stopWhenIdle>Y</stopWhenIdle>
+    <maxIdleTimeMs>5000</maxIdleTimeMs>
     <subTransform>Out record</subTransform>
     <directBootstrapServers>${BOOTSTRAP_SERVERS}</directBootstrapServers>
     <topic>hop-test-0002</topic>
@@ -128,37 +130,12 @@ limitations under the License.
     </partitioning>
     <attributes/>
   </transform>
-  <transform>
-    <type>Abort</type>
-    <name>Stop Kafka Processing</name>
-    <row_threshold>0</row_threshold>
-    <message>Kafka consumer stopped</message>
-    <always_log_rows>Y</always_log_rows>
-    <abort_option>SAFE_STOP</abort_option>
-    <distribute>Y</distribute>
-    <copies>1</copies>
-    <GUI>
-      <xloc>672</xloc>
-      <yloc>112</yloc>
-    </GUI>
-    <description/>
-    <partitioning>
-      <method>none</method>
-      <schema_name/>
-    </partitioning>
-    <attributes/>
-  </transform>
   <order>
     <hop>
       <from>Kafka Consumer</from>
       <to>Log Output</to>
       <enabled>Y</enabled>
     </hop>
-    <hop>
-      <from>Log Output</from>
-      <to>Stop Kafka Processing</to>
-      <enabled>Y</enabled>
-    </hop>
   </order>
   <notepads/>
   <attributes/>
diff --git a/integration-tests/kafka/0003-kafka-consumer-called-subpipeline.hpl 
b/integration-tests/kafka/0003-kafka-consumer-called-subpipeline.hpl
new file mode 100644
index 0000000000..4d0117a1f4
--- /dev/null
+++ b/integration-tests/kafka/0003-kafka-consumer-called-subpipeline.hpl
@@ -0,0 +1,112 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+  <info>
+    <name>0003-kafka-consumer-called-subpipeline</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <description/>
+    <extended_description/>
+    <pipeline_version/>
+    <pipeline_type>Normal</pipeline_type>
+    <parameters>
+    </parameters>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <created_user>-</created_user>
+    <created_date>2021/12/21 09:43:31.432</created_date>
+    <modified_user>-</modified_user>
+    <modified_date>2021/12/21 09:43:31.432</modified_date>
+    <key_for_session_key>H4sIAAAAAAAAAAMAAAAAAAAAAAA=</key_for_session_key>
+    <is_key_private>N</is_key_private>
+  </info>
+  <notepads>
+  </notepads>
+  <order>
+    <hop>
+      <from>Injector</from>
+      <to>Out record</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <transform>
+    <name>Out record</name>
+    <type>Dummy</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+    <GUI>
+      <xloc>384</xloc>
+      <yloc>112</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>Injector</name>
+    <type>Injector</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <fields>
+      <field>
+        <name>key</name>
+        <type>String</type>
+      </field>
+      <field>
+        <name>message</name>
+        <type>String</type>
+      </field>
+      <field>
+        <name>topic</name>
+        <type>String</type>
+      </field>
+      <field>
+        <name>partition</name>
+        <type>Integer</type>
+      </field>
+      <field>
+        <name>offset</name>
+        <type>Integer</type>
+      </field>
+      <field>
+        <name>timestamp</name>
+        <type>Integer</type>
+      </field>
+    </fields>
+    <attributes/>
+    <GUI>
+      <xloc>240</xloc>
+      <yloc>112</yloc>
+    </GUI>
+  </transform>
+  <transform_error_handling>
+  </transform_error_handling>
+  <attributes/>
+</pipeline>
diff --git a/integration-tests/kafka/0001-kafka-consumer-read-record-basic.hpl 
b/integration-tests/kafka/0003-kafka-consumer-read-stop-when-idle.hpl
similarity index 82%
copy from integration-tests/kafka/0001-kafka-consumer-read-record-basic.hpl
copy to integration-tests/kafka/0003-kafka-consumer-read-stop-when-idle.hpl
index f10879691b..2c7f79b7c4 100644
--- a/integration-tests/kafka/0001-kafka-consumer-read-record-basic.hpl
+++ b/integration-tests/kafka/0003-kafka-consumer-read-stop-when-idle.hpl
@@ -19,7 +19,7 @@ limitations under the License.
 -->
 <pipeline>
   <info>
-    <name>0001-kafka-consumer-read-record-basic</name>
+    <name>0003-kafka-consumer-read-stop-when-idle</name>
     <name_sync_with_filename>Y</name_sync_with_filename>
     <description/>
     <extended_description/>
@@ -46,11 +46,6 @@ limitations under the License.
       <to>Log Output</to>
       <enabled>Y</enabled>
     </hop>
-    <hop>
-      <from>Log Output</from>
-      <to>Stop Kafka Processing</to>
-      <enabled>Y</enabled>
-    </hop>
   </order>
   <transform>
     <name>Kafka Consumer</name>
@@ -63,12 +58,14 @@ limitations under the License.
       <method>none</method>
       <schema_name/>
     </partitioning>
-    <topic>hop-test</topic>
+    <topic>hop-test-idle</topic>
     <consumerGroup/>
-    
<pipelinePath>${PROJECT_HOME}/0001-kafka-consumer-called-subpipeline.hpl</pipelinePath>
+    
<pipelinePath>${PROJECT_HOME}/0003-kafka-consumer-called-subpipeline.hpl</pipelinePath>
     <subTransform>Out record</subTransform>
-    <batchSize>1</batchSize>
+    <batchSize>10</batchSize>
     <batchDuration>1000</batchDuration>
+    <stopWhenIdle>Y</stopWhenIdle>
+    <maxIdleTimeMs>5000</maxIdleTimeMs>
     <directBootstrapServers>${BOOTSTRAP_SERVERS}</directBootstrapServers>
     <AUTO_COMMIT>N</AUTO_COMMIT>
     <OutputField kafkaName="key" type="String">Key</OutputField>
@@ -118,27 +115,6 @@ limitations under the License.
       <yloc>112</yloc>
     </GUI>
   </transform>
-  <transform>
-    <name>Stop Kafka Processing</name>
-    <type>Abort</type>
-    <description/>
-    <distribute>Y</distribute>
-    <custom_distribution/>
-    <copies>1</copies>
-    <partitioning>
-      <method>none</method>
-      <schema_name/>
-    </partitioning>
-    <abort_option>SAFE_STOP</abort_option>
-    <always_log_rows>Y</always_log_rows>
-    <message>Kafka consumer stopped</message>
-    <row_threshold>0</row_threshold>
-    <attributes/>
-    <GUI>
-      <xloc>672</xloc>
-      <yloc>112</yloc>
-    </GUI>
-  </transform>
   <transform_error_handling>
   </transform_error_handling>
   <attributes/>
diff --git a/integration-tests/kafka/main-0003-kafka-test-stop-when-idle.hwf 
b/integration-tests/kafka/main-0003-kafka-test-stop-when-idle.hwf
new file mode 100644
index 0000000000..f4aa237a77
--- /dev/null
+++ b/integration-tests/kafka/main-0003-kafka-test-stop-when-idle.hwf
@@ -0,0 +1,171 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+  <name>main-0003-kafka-test-stop-when-idle</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description/>
+  <extended_description/>
+  <created_user>-</created_user>
+  <modified_user>-</modified_user>
+  <created_date>2021/12/30 10:42:13.629</created_date>
+  <modified_date>2021/12/30 10:42:13.629</modified_date>
+  <workflow_version/>
+  <parameters>
+    <parameter>
+      <name>BOOTSTRAP_SERVERS</name>
+      <description/>
+      <default_value>kafka:9092</default_value>
+    </parameter>
+  </parameters>
+  <actions>
+    <action>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <intervalSeconds>0</intervalSeconds>
+      <intervalMinutes>60</intervalMinutes>
+      <DayOfMonth>1</DayOfMonth>
+      <weekDay>1</weekDay>
+      <minutes>0</minutes>
+      <hour>12</hour>
+      <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <xloc>128</xloc>
+      <yloc>144</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      
<filename>${PROJECT_HOME}/prepare-kafka-test-0003-stop-when-idle.hpl</filename>
+      <params_from_previous>N</params_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <clear_rows>N</clear_rows>
+      <clear_files>N</clear_files>
+      <create_parent_folder>N</create_parent_folder>
+      <set_logfile>N</set_logfile>
+      <set_append_logfile>N</set_append_logfile>
+      <logfile/>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <loglevel>Basic</loglevel>
+      <wait_until_finished>Y</wait_until_finished>
+      <parameters>
+        <pass_all_parameters>Y</pass_all_parameters>
+      </parameters>
+      <run_configuration>local</run_configuration>
+      <name>prepare-kafka-test-0003-stop-when-idle.hpl</name>
+      <description/>
+      <type>PIPELINE</type>
+      <attributes/>
+      <xloc>336</xloc>
+      <yloc>144</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      <script>
+var txt = previous_result.getLogText();
+
+var ok = false;
+
+var expectedValues = [
+   "Message = Hello Hop Idle!",
+ ];
+
+for (var i = 0 ; i&lt;expectedValues.length ; i++) {
+  var expectedValue = expectedValues[i];
+  if (txt.contains(expectedValue)) {
+    ok = true;
+    log.logBasic("Expected value logged as ''" + expectedValue + "'' FOUND!");
+  }
+}
+
+if (!ok) {
+    log.logBasic("Expected value logged as ''" + expectedValues[0] + "'' NOT 
FOUND!");
+}
+
+ok;</script>
+      <name>Check log</name>
+      <description/>
+      <type>EVAL</type>
+      <attributes/>
+      <xloc>864</xloc>
+      <yloc>144</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      
<filename>${PROJECT_HOME}/0003-kafka-consumer-read-stop-when-idle.hpl</filename>
+      <params_from_previous>N</params_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <clear_rows>N</clear_rows>
+      <clear_files>N</clear_files>
+      <create_parent_folder>N</create_parent_folder>
+      <set_logfile>N</set_logfile>
+      <set_append_logfile>N</set_append_logfile>
+      <logfile/>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <loglevel>Basic</loglevel>
+      <wait_until_finished>Y</wait_until_finished>
+      <parameters>
+        <pass_all_parameters>Y</pass_all_parameters>
+      </parameters>
+      <run_configuration>local</run_configuration>
+      <name>0003-kafka-consumer-read-stop-when-idle.hpl</name>
+      <description/>
+      <type>PIPELINE</type>
+      <attributes/>
+      <xloc>608</xloc>
+      <yloc>144</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>prepare-kafka-test-0003-stop-when-idle.hpl</to>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>0003-kafka-consumer-read-stop-when-idle.hpl</from>
+      <to>Check log</to>
+      <evaluation>N</evaluation>
+      <unconditional>Y</unconditional>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>prepare-kafka-test-0003-stop-when-idle.hpl</from>
+      <to>0003-kafka-consumer-read-stop-when-idle.hpl</to>
+      <evaluation>N</evaluation>
+      <unconditional>Y</unconditional>
+      <enabled>Y</enabled>
+    </hop>
+  </hops>
+  <notepads/>
+  <attributes/>
+</workflow>
diff --git a/integration-tests/kafka/0001-kafka-consumer-read-record-basic.hpl 
b/integration-tests/kafka/prepare-kafka-test-0003-stop-when-idle.hpl
similarity index 55%
copy from integration-tests/kafka/0001-kafka-consumer-read-record-basic.hpl
copy to integration-tests/kafka/prepare-kafka-test-0003-stop-when-idle.hpl
index f10879691b..2b8c534ee6 100644
--- a/integration-tests/kafka/0001-kafka-consumer-read-record-basic.hpl
+++ b/integration-tests/kafka/prepare-kafka-test-0003-stop-when-idle.hpl
@@ -19,22 +19,21 @@ limitations under the License.
 -->
 <pipeline>
   <info>
-    <name>0001-kafka-consumer-read-record-basic</name>
+    <name>prepare-kafka-test-0003-stop-when-idle</name>
     <name_sync_with_filename>Y</name_sync_with_filename>
     <description/>
     <extended_description/>
     <pipeline_version/>
     <pipeline_type>Normal</pipeline_type>
-    <pipeline_status>0</pipeline_status>
     <parameters>
     </parameters>
     <capture_transform_performance>N</capture_transform_performance>
     
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
     
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
     <created_user>-</created_user>
-    <created_date>2021/12/21 09:42:58.199</created_date>
+    <created_date>2021/12/21 09:37:38.673</created_date>
     <modified_user>-</modified_user>
-    <modified_date>2021/12/21 09:42:58.199</modified_date>
+    <modified_date>2021/12/21 09:37:38.673</modified_date>
     <key_for_session_key>H4sIAAAAAAAAAAMAAAAAAAAAAAA=</key_for_session_key>
     <is_key_private>N</is_key_private>
   </info>
@@ -42,58 +41,14 @@ limitations under the License.
   </notepads>
   <order>
     <hop>
-      <from>Kafka Consumer</from>
-      <to>Log Output</to>
-      <enabled>Y</enabled>
-    </hop>
-    <hop>
-      <from>Log Output</from>
-      <to>Stop Kafka Processing</to>
+      <from>Generate rows</from>
+      <to>Kafka Producer</to>
       <enabled>Y</enabled>
     </hop>
   </order>
   <transform>
-    <name>Kafka Consumer</name>
-    <type>KafkaConsumer</type>
-    <description/>
-    <distribute>Y</distribute>
-    <custom_distribution/>
-    <copies>1</copies>
-    <partitioning>
-      <method>none</method>
-      <schema_name/>
-    </partitioning>
-    <topic>hop-test</topic>
-    <consumerGroup/>
-    
<pipelinePath>${PROJECT_HOME}/0001-kafka-consumer-called-subpipeline.hpl</pipelinePath>
-    <subTransform>Out record</subTransform>
-    <batchSize>1</batchSize>
-    <batchDuration>1000</batchDuration>
-    <directBootstrapServers>${BOOTSTRAP_SERVERS}</directBootstrapServers>
-    <AUTO_COMMIT>N</AUTO_COMMIT>
-    <OutputField kafkaName="key" type="String">Key</OutputField>
-    <OutputField kafkaName="message" type="String">Message</OutputField>
-    <OutputField kafkaName="topic" type="String">Topic</OutputField>
-    <OutputField kafkaName="partition" type="Integer">Partition</OutputField>
-    <OutputField kafkaName="offset" type="Integer">Offset</OutputField>
-    <OutputField kafkaName="timestamp" type="Integer">Timestamp</OutputField>
-    <advancedConfig>
-      <option property="auto.offset.reset" value="earliest"/>
-      <option property="ssl.key.password" value=""/>
-      <option property="ssl.keystore.location" value=""/>
-      <option property="ssl.keystore.password" value=""/>
-      <option property="ssl.truststore.location" value=""/>
-      <option property="ssl.truststore.password" value=""/>
-    </advancedConfig>
-    <attributes/>
-    <GUI>
-      <xloc>448</xloc>
-      <yloc>112</yloc>
-    </GUI>
-  </transform>
-  <transform>
-    <name>Log Output</name>
-    <type>WriteToLog</type>
+    <name>Generate rows</name>
+    <type>RowGenerator</type>
     <description/>
     <distribute>Y</distribute>
     <custom_distribution/>
@@ -102,25 +57,30 @@ limitations under the License.
       <method>none</method>
       <schema_name/>
     </partitioning>
-    <loglevel>log_level_basic</loglevel>
-    <displayHeader>Y</displayHeader>
-    <limitRows>N</limitRows>
-    <limitRowsNumber>0</limitRowsNumber>
-    <logmessage>Kafka Output</logmessage>
     <fields>
       <field>
-        <name>message</name>
+        <length>-1</length>
+        <name>field</name>
+        <precision>-1</precision>
+        <set_empty_string>N</set_empty_string>
+        <type>String</type>
+        <nullif>Hello Hop Idle!</nullif>
       </field>
     </fields>
+    <interval_in_ms>5000</interval_in_ms>
+    <last_time_field>FiveSecondsAgo</last_time_field>
+    <never_ending>N</never_ending>
+    <limit>3</limit>
+    <row_time_field>now</row_time_field>
     <attributes/>
     <GUI>
-      <xloc>560</xloc>
-      <yloc>112</yloc>
+      <xloc>288</xloc>
+      <yloc>160</yloc>
     </GUI>
   </transform>
   <transform>
-    <name>Stop Kafka Processing</name>
-    <type>Abort</type>
+    <name>Kafka Producer</name>
+    <type>KafkaProducerOutput</type>
     <description/>
     <distribute>Y</distribute>
     <custom_distribution/>
@@ -129,14 +89,23 @@ limitations under the License.
       <method>none</method>
       <schema_name/>
     </partitioning>
-    <abort_option>SAFE_STOP</abort_option>
-    <always_log_rows>Y</always_log_rows>
-    <message>Kafka consumer stopped</message>
-    <row_threshold>0</row_threshold>
+    <directBootstrapServers>${BOOTSTRAP_SERVERS}</directBootstrapServers>
+    <topic>hop-test-idle</topic>
+    <clientId/>
+    <keyField/>
+    <messageField>field</messageField>
+    <advancedConfig>
+      <option property="compression.type" value="none"/>
+      <option property="ssl.key.password" value=""/>
+      <option property="ssl.keystore.location" value=""/>
+      <option property="ssl.keystore.password" value=""/>
+      <option property="ssl.truststore.location" value=""/>
+      <option property="ssl.truststore.password" value=""/>
+    </advancedConfig>
     <attributes/>
     <GUI>
-      <xloc>672</xloc>
-      <yloc>112</yloc>
+      <xloc>464</xloc>
+      <yloc>160</yloc>
     </GUI>
   </transform>
   <transform_error_handling>
diff --git 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInput.java
 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInput.java
index 95bcd2f40c..7a49d84502 100644
--- 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInput.java
+++ 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInput.java
@@ -85,6 +85,9 @@ public class KafkaConsumerInput
     data.incomingRowsBuffer = new ArrayList<>();
     data.batchDuration = Const.toInt(resolve(meta.getBatchDuration()), 0);
     data.batchSize = Const.toInt(resolve(meta.getBatchSize()), 0);
+    data.stopWhenIdle = meta.isStopWhenIdle();
+    data.maxIdleTimeMs = Const.toLong(resolve(meta.getMaxIdleTimeMs()), 500L);
+    data.lastRecordTime = System.currentTimeMillis();
 
     data.consumer = buildKafkaConsumer(this, meta);
 
@@ -279,16 +282,36 @@ public class KafkaConsumerInput
 
     // Poll records...
     // If we get any, process them...
+    // When stop-when-idle is enabled, use a short poll timeout so idle time 
can be measured.
     //
     try {
-      Duration duration =
-          Duration.ofMillis(data.batchDuration > 0 ? data.batchDuration : 
Long.MAX_VALUE);
+      long pollMs =
+          data.stopWhenIdle ? 100L : (data.batchDuration > 0 ? 
data.batchDuration : Long.MAX_VALUE);
+      Duration duration = Duration.ofMillis(pollMs);
       ConsumerRecords<Object, Object> records = data.consumer.poll(duration);
 
       if (!data.isKafkaConsumerClosing) {
         if (records.isEmpty()) {
-          // We can just skip this one, poll again next iteration of this 
method
+          // No records: optionally stop after max idle time.
+          // Do not count idle until partitions are assigned — group join / 
rebalance can take
+          // longer than maxIdleTimeMs and would otherwise stop before any 
poll can succeed.
           //
+          if (data.stopWhenIdle) {
+            if (data.consumer.assignment() == null || 
data.consumer.assignment().isEmpty()) {
+              data.lastRecordTime = System.currentTimeMillis();
+            } else if ((System.currentTimeMillis() - data.lastRecordTime) >= 
data.maxIdleTimeMs) {
+              logBasic(
+                  "Kafka consumer idle timeout of "
+                      + data.maxIdleTimeMs
+                      + "ms exceeded, stopping gracefully");
+              data.isKafkaConsumerClosing = true;
+              if (data.executor != null) {
+                data.executor.getPipeline().stopAll();
+              }
+              setOutputDone();
+              return false;
+            }
+          }
         } else {
           // Grab the records...
           //
@@ -300,6 +323,7 @@ public class KafkaConsumerInput
             }
             incrementLinesInput();
           }
+          data.lastRecordTime = System.currentTimeMillis();
           if (isBasic()) {
             logBasic("Number of rows read: " + 
data.rowProducer.getRowSet().size());
           }
diff --git 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputData.java
 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputData.java
index 85cd933b92..f96ea2b88b 100644
--- 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputData.java
+++ 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputData.java
@@ -31,6 +31,9 @@ public class KafkaConsumerInputData extends BaseTransformData 
implements ITransf
   public Consumer<Object, Object> consumer;
   public int batchDuration;
   public int batchSize;
+  public boolean stopWhenIdle;
+  public long maxIdleTimeMs;
+  public long lastRecordTime;
   public RowProducer rowProducer;
   public SingleThreadedPipelineExecutor executor;
   public boolean isKafkaConsumerClosing;
diff --git 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputDialog.java
 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputDialog.java
index 2c63c7a308..1a5c7e67a2 100644
--- 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputDialog.java
+++ 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputDialog.java
@@ -106,6 +106,9 @@ public class KafkaConsumerInputDialog extends 
BaseTransformDialog {
   protected TextVar wBatchSize;
   protected Label wlBatchDuration;
   protected TextVar wBatchDuration;
+  protected Button wStopWhenIdle;
+  protected Label wlMaxIdleTimeMs;
+  protected TextVar wMaxIdleTimeMs;
 
   protected CTabFolder wTabFolder;
   protected CTabItem wSetupTab;
@@ -299,6 +302,8 @@ public class KafkaConsumerInputDialog extends 
BaseTransformDialog {
     m.setExecutionDataProfile(wProfile.getText());
     m.setBatchSize(wBatchSize.getText());
     m.setBatchDuration(wBatchDuration.getText());
+    m.setStopWhenIdle(wStopWhenIdle.getSelection());
+    m.setMaxIdleTimeMs(wMaxIdleTimeMs.getText());
     m.setSubTransform(wSubTransform.getText());
     setTopicsFromTable();
 
@@ -329,7 +334,7 @@ public class KafkaConsumerInputDialog extends 
BaseTransformDialog {
     wOffsetGroup.setLayout(flOffsetGroup);
 
     FormData fdOffsetGroup = new FormData();
-    fdOffsetGroup.top = new FormAttachment(wBatchSize, 15);
+    fdOffsetGroup.top = new FormAttachment(wMaxIdleTimeMs, 15);
     fdOffsetGroup.left = new FormAttachment(0, 0);
     fdOffsetGroup.right = new FormAttachment(100, 0);
     wOffsetGroup.setLayoutData(fdOffsetGroup);
@@ -614,6 +619,34 @@ public class KafkaConsumerInputDialog extends 
BaseTransformDialog {
     fdBatchSize.top = new FormAttachment(wlBatchSize, 0, SWT.CENTER);
     wBatchSize.setLayoutData(fdBatchSize);
 
+    wStopWhenIdle = new Button(wBatchComp, SWT.CHECK);
+    PropsUi.setLook(wStopWhenIdle);
+    wStopWhenIdle.setText(BaseMessages.getString(PKG, 
"KafkaConsumerInputDialog.StopWhenIdle"));
+    wStopWhenIdle.addListener(SWT.Selection, e -> meta.setChanged());
+    FormData fdStopWhenIdle = new FormData();
+    fdStopWhenIdle.left = new FormAttachment(middle, 0);
+    fdStopWhenIdle.top = new FormAttachment(wBatchSize, margin);
+    fdStopWhenIdle.right = new FormAttachment(100, 0);
+    wStopWhenIdle.setLayoutData(fdStopWhenIdle);
+
+    wlMaxIdleTimeMs = new Label(wBatchComp, SWT.RIGHT);
+    PropsUi.setLook(wlMaxIdleTimeMs);
+    wlMaxIdleTimeMs.setText(BaseMessages.getString(PKG, 
"KafkaConsumerInputDialog.MaxIdleTimeMs"));
+    FormData fdlMaxIdleTimeMs = new FormData();
+    fdlMaxIdleTimeMs.left = new FormAttachment(0, 0);
+    fdlMaxIdleTimeMs.top = new FormAttachment(wStopWhenIdle, margin);
+    fdlMaxIdleTimeMs.right = new FormAttachment(middle, -margin);
+    wlMaxIdleTimeMs.setLayoutData(fdlMaxIdleTimeMs);
+
+    wMaxIdleTimeMs = new TextVar(variables, wBatchComp, SWT.SINGLE | SWT.LEFT 
| SWT.BORDER);
+    PropsUi.setLook(wMaxIdleTimeMs);
+    wMaxIdleTimeMs.addModifyListener(lsMod);
+    FormData fdMaxIdleTimeMs = new FormData();
+    fdMaxIdleTimeMs.left = new FormAttachment(wlMaxIdleTimeMs, margin);
+    fdMaxIdleTimeMs.right = new FormAttachment(100, 0);
+    fdMaxIdleTimeMs.top = new FormAttachment(wlMaxIdleTimeMs, 0, SWT.CENTER);
+    wMaxIdleTimeMs.setLayoutData(fdMaxIdleTimeMs);
+
     wBatchComp.layout();
     wBatchTab.setControl(wBatchComp);
   }
@@ -827,6 +860,8 @@ public class KafkaConsumerInputDialog extends 
BaseTransformDialog {
     wConsumerGroup.setText(Const.NVL(meta.getConsumerGroup(), ""));
     wBatchSize.setText(Const.NVL(meta.getBatchSize(), ""));
     wBatchDuration.setText(Const.NVL(meta.getBatchDuration(), ""));
+    wStopWhenIdle.setSelection(meta.isStopWhenIdle());
+    wMaxIdleTimeMs.setText(Const.NVL(meta.getMaxIdleTimeMs(), "500"));
 
     wbAutoCommit.setSelection(meta.isAutoCommit());
     wbManualCommit.setSelection(!meta.isAutoCommit());
diff --git 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java
 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java
index 6004f74d56..6ee54fe7e3 100644
--- 
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java
+++ 
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java
@@ -167,6 +167,18 @@ public class KafkaConsumerInputMeta
       injectionKeyDescription = "KafkaConsumerInputMeta.Injection.AUTO_COMMIT")
   private boolean autoCommit = true;
 
+  @HopMetadataProperty(
+      key = "stopWhenIdle",
+      injectionKey = "STOP_WHEN_IDLE",
+      injectionKeyDescription = 
"KafkaConsumerInputMeta.Injection.STOP_WHEN_IDLE")
+  private boolean stopWhenIdle = false;
+
+  @HopMetadataProperty(
+      key = "maxIdleTimeMs",
+      injectionKey = "MAX_IDLE_TIME_MS",
+      injectionKeyDescription = 
"KafkaConsumerInputMeta.Injection.MAX_IDLE_TIME_MS")
+  private String maxIdleTimeMs;
+
   @HopMetadataProperty(
       groupKey = "options",
       key = "option",
@@ -194,6 +206,7 @@ public class KafkaConsumerInputMeta
     executionDataProfile = "";
     batchSize = "1000";
     batchDuration = "1000";
+    maxIdleTimeMs = "500";
     subTransform = "";
     topics = new ArrayList<>();
     options = new ArrayList<>();
@@ -243,6 +256,8 @@ public class KafkaConsumerInputMeta
     this.topics = new ArrayList<>(m.topics);
     this.consumerGroup = m.consumerGroup;
     this.autoCommit = m.autoCommit;
+    this.stopWhenIdle = m.stopWhenIdle;
+    this.maxIdleTimeMs = m.maxIdleTimeMs;
     this.mappingMetaRetriever = m.mappingMetaRetriever;
     this.options = new ArrayList<>();
     m.options.forEach(o -> this.options.add(new KafkaOption(o)));
@@ -327,8 +342,13 @@ public class KafkaConsumerInputMeta
       // Check if we get called from error path and only in that case, show 
fields that will dump
       // the
       // record coming from the kafka queue.
-      TransformErrorMeta transformErrorMeta = 
getParentTransformMeta().getTransformErrorMeta();
+      TransformErrorMeta transformErrorMeta =
+          getParentTransformMeta() != null
+              ? getParentTransformMeta().getTransformErrorMeta()
+              : null;
       if (transformErrorMeta != null
+          && transformErrorMeta.getTargetTransform() != null
+          && nextTransform != null
           && 
transformErrorMeta.getTargetTransform().getName().equals(nextTransform.getName()))
 {
         
rowMeta.addValueMeta(createValueMetaString(getKeyField().getOutputName()));
         
rowMeta.addValueMeta(createValueMetaString(getMessageField().getOutputName()));
@@ -398,6 +418,19 @@ public class KafkaConsumerInputMeta
               BaseMessages.getString(PKG, 
"KafkaConsumerInputMeta.CheckResult.NoBatchDefined"),
               transformMeta));
     }
+
+    if (isStopWhenIdle()) {
+      try {
+        Long.parseLong(variables.resolve(getMaxIdleTimeMs()));
+      } catch (NumberFormatException e) {
+        remarks.add(
+            new CheckResult(
+                ICheckResult.TYPE_RESULT_ERROR,
+                BaseMessages.getString(
+                    PKG, "KafkaConsumerInputMeta.CheckResult.NaN", "Max idle 
time"),
+                transformMeta));
+      }
+    }
   }
 
   @Override
diff --git 
a/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/consumer/messages/messages_en_US.properties
 
b/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/consumer/messages/messages_en_US.properties
index 7b3fa45c3e..a2eb21bdcf 100644
--- 
a/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/consumer/messages/messages_en_US.properties
+++ 
b/plugins/transforms/kafka/src/main/resources/org/apache/hop/pipeline/transforms/kafka/consumer/messages/messages_en_US.properties
@@ -24,6 +24,8 @@ KafkaConsumerInputDialog.BatchDuration=Duration (ms)
 KafkaConsumerInputDialog.BatchSize=Number of records
 KafkaConsumerInputDialog.BatchTab=Batch
 KafkaConsumerInputDialog.BootstrapServers=Bootstrap servers
+KafkaConsumerInputDialog.MaxIdleTimeMs=Max idle time (ms)
+KafkaConsumerInputDialog.StopWhenIdle=Stop when idle
 KafkaConsumerInputDialog.Column.Name=Output name
 KafkaConsumerInputDialog.Column.Ref=Input name
 KafkaConsumerInputDialog.Column.Type=Type
@@ -62,6 +64,8 @@ KafkaConsumerInputMeta.Injection.CONNECTION_TYPE=Specify the 
connection type: DI
 KafkaConsumerInputMeta.Injection.CONSUMER_GROUP=The name of the Kafka consumer 
group. This is a required field.
 KafkaConsumerInputMeta.Injection.DIRECT_BOOTSTRAP_SERVERS=Specify the 
Bootstrap Servers when Connection Type is DIRECT.
 KafkaConsumerInputMeta.Injection.BATCH_DURATION=The amount of time to batch 
before consuming the messages.
+KafkaConsumerInputMeta.Injection.STOP_WHEN_IDLE=Stop the Kafka consumer when 
no records are received for the max idle time.
+KafkaConsumerInputMeta.Injection.MAX_IDLE_TIME_MS=The maximum idle time in 
milliseconds before stopping when stop when idle is enabled.
 KafkaConsumerInputMeta.Injection.KEY.OUTPUT_NAME=The name of the output field 
for the key.
 KafkaConsumerInputMeta.Injection.KEY.TYPE=Specify the data type for the key: 
String, Integer, Binary, or Number.
 KafkaConsumerInputMeta.Injection.MESSAGE.OUTPUT_NAME=The name of the output 
field for the message.
diff --git 
a/plugins/transforms/kafka/src/test/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMetaTest.java
 
b/plugins/transforms/kafka/src/test/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMetaTest.java
index 8a095cc35a..4ab6a3d853 100644
--- 
a/plugins/transforms/kafka/src/test/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMetaTest.java
+++ 
b/plugins/transforms/kafka/src/test/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMetaTest.java
@@ -104,6 +104,8 @@ class KafkaConsumerInputMetaTest {
     meta.setBatchSize("111");
     meta.setBatchDuration("222");
     meta.setAutoCommit(true);
+    meta.setStopWhenIdle(true);
+    meta.setMaxIdleTimeMs("1500");
     meta.getOptions().clear();
     meta.getOptions().add(new KafkaOption("auto.offset.reset", "latest"));
     meta.getOptions().add(new KafkaOption("ssl.key.password", ""));
@@ -143,6 +145,8 @@ class KafkaConsumerInputMetaTest {
     assertEquals("profile", copy.getExecutionDataProfile());
     assertEquals("111", copy.getBatchSize());
     assertEquals("222", copy.getBatchDuration());
+    assertTrue(copy.isStopWhenIdle());
+    assertEquals("1500", copy.getMaxIdleTimeMs());
     assertTrue(copy.getTopics().contains("topic1"));
     assertTrue(copy.getTopics().contains("topic2"));
 
@@ -193,6 +197,9 @@ class KafkaConsumerInputMetaTest {
     assertEquals("10000", meta.getBatchDuration());
     assertEquals("${KAFKA_SERVER}", meta.getDirectBootstrapServers());
     assertFalse(meta.isAutoCommit());
+    // Legacy XML without the new fields keeps defaults
+    assertFalse(meta.isStopWhenIdle());
+    assertEquals("500", meta.getMaxIdleTimeMs());
 
     assertEquals(6, meta.getOptions().size());
     assertEquals("auto.offset.reset", 
meta.getOptions().getFirst().getProperty());
@@ -228,4 +235,22 @@ class KafkaConsumerInputMetaTest {
     assertEquals(KafkaConsumerField.Type.Integer, f.getOutputType());
     assertEquals(KafkaConsumerField.Name.TIMESTAMP, f.getKafkaName());
   }
+
+  @Test
+  void testStopWhenIdleDefaults() {
+    KafkaConsumerInputMeta meta = new KafkaConsumerInputMeta();
+    assertFalse(meta.isStopWhenIdle());
+    assertEquals("500", meta.getMaxIdleTimeMs());
+  }
+
+  @Test
+  void testStopWhenIdleClone() {
+    KafkaConsumerInputMeta meta = new KafkaConsumerInputMeta();
+    meta.setStopWhenIdle(true);
+    meta.setMaxIdleTimeMs("2500");
+
+    KafkaConsumerInputMeta copy = meta.clone();
+    assertTrue(copy.isStopWhenIdle());
+    assertEquals("2500", copy.getMaxIdleTimeMs());
+  }
 }


Reply via email to