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

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


The following commit(s) were added to refs/heads/main by this push:
     new 50d906acc6 TIKA-4833 -- fix Kafka iterator (#3048)
50d906acc6 is described below

commit 50d906acc65108ff580d11fbfb9c9b92a8cdc544
Author: Tim Allison <[email protected]>
AuthorDate: Sat Aug 22 16:01:19 2026 -0400

    TIKA-4833 -- fix Kafka iterator (#3048)
---
 CHANGES.txt                                        |  9 +++
 docs/modules/ROOT/pages/pipes/plugins/kafka.adoc   | 74 +++++++++++++++++++---
 .../tika/pipes/kafka/tests/TikaPipesKafkaTest.java | 50 +++++++++++++--
 .../src/test/resources/kafka/plugins-template.json |  2 +-
 .../pipes/iterator/kafka/KafkaPipesIterator.java   | 46 ++++++++++++--
 .../iterator/kafka/KafkaPipesIteratorConfig.java   | 21 ++++++
 6 files changed, 180 insertions(+), 22 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 5c32de6a3d..eea5ad329b 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,14 @@
 Release 4.1.0 - unreleased
 
+   * The Kafka pipes iterator no longer stops at the first empty poll. A newly
+     subscribed consumer spends its first poll(s) joining the group and returns
+     empty even when the topic has a backlog, so the iterator could enqueue 
zero
+     files and report success. It now waits for a partition assignment (bounded
+     by the new assignmentTimeoutMs, default 30s) and requires a continuous 
quiet
+     window (drainIdleMs, default 1s) before concluding the topic is drained.
+     groupInitialRebalanceDelayMs is deprecated and no longer sent to the
+     consumer: it is a broker setting that Kafka has always ignored 
(TIKA-4833).
+
    * Pipes IPC: carry inline document bytes as a raw binary field beside the
      tuple in the request envelope -- never inside the tuple or its
      ParseContext -- and disable Smile's 7-bit binary encoding. Tuple JSON
diff --git a/docs/modules/ROOT/pages/pipes/plugins/kafka.adoc 
b/docs/modules/ROOT/pages/pipes/plugins/kafka.adoc
index 99178e8827..e3b448e321 100644
--- a/docs/modules/ROOT/pages/pipes/plugins/kafka.adoc
+++ b/docs/modules/ROOT/pages/pipes/plugins/kafka.adoc
@@ -21,6 +21,11 @@
 
 The Apache Kafka plugin (`tika-pipes-kafka`) provides an emitter (publishes 
parsed documents to a Kafka topic) and an iterator (consumes fetch requests 
from a Kafka topic).
 
+The two halves have different standing. The *emitter* is a plain producer and 
a good fit: Tika
+parses, results stream to a topic for downstream indexing. The *iterator* is a 
worked example
+whose suitability depends on your parse latency -- see <<iterator-caveats>> 
before building on
+it.
+
 [cols="2,1,3"]
 |===
 |Interface |Component name |Class
@@ -146,12 +151,47 @@ applies. The Default column below is therefore the Kafka 
client's default.
 [#kafka-iterator]
 == Kafka Iterator (`kafka-pipes-iterator`)
 
-Consumes fetch-request messages from a Kafka topic and emits one 
`FetchEmitTuple` per message. Useful for building event-driven pipelines where 
some upstream system pushes work to a queue.
-
-[source,json]
-----
-include::example$pipes-kafka-iterator.json[]
-----
+[WARNING]
+.Reference example -- check that your parse latency suits it
+====
+Kafka's consumer model assumes bounded, roughly uniform per-message processing 
time, so how
+well this component works depends on *how long your documents take to parse*. 
Short, predictable
+parses fit that assumption. Long-running parses do not: pairing Kafka with 
documents that take
+minutes to an hour -- OCR'd PDFs, large archives, anything near the one-hour 
default total-task
+timeout -- works against the grain of the offset model, and the caveats below 
stop being
+theoretical.
+
+It is kept as a worked example rather than a supported production integration. 
If your workload
+has a long latency tail, consider driving tika-server or tika-grpc from your 
own consumer, where
+you control acknowledgement and retry, and use the <<kafka-emitter,Kafka 
emitter>> to publish
+results.
+====
+
+[#iterator-caveats]
+=== Things to know before building on it
+
+*Head-of-line blocking, in proportion to your latency spread.* A partition is 
consumed in order
+by one member of the group. Even with `numClients` workers parsing in 
parallel, correct offset
+handling can only advance the commit watermark to the lowest un-acknowledged 
offset, so one slow
+document holds up its partition's progress. With short, uniform parses this is 
barely
+noticeable; with a long tail, one document can stall a partition for as long 
as it parses.
+This follows from Kafka's offset model rather than from a setting.
+
+*At-most-once delivery.* `enable.auto.commit` is left at Kafka's default of 
`true`, so offsets
+are committed on a timer as soon as records are *polled* -- before Tika has 
parsed or emitted
+them. A crash, OOM or failed emit in between loses those documents silently. 
Committing after a
+successful emit is not currently possible: the iterator pushes tuples onto a 
queue and receives
+no completion signal back.
+
+*It drains and exits; it does not stream.* The iterator enqueues what is on 
the topic and then
+finishes, because tika-pipes' iterator contract is finite. A quiet period ends 
the run (see
+`drainIdleMs`). It suits a periodic batch drain, not a long-running consumer.
+
+*Give the consumer room to join.* A stock Kafka broker applies
+`group.initial.rebalance.delay.ms` (default 3000) to the first member joining 
an *empty* group.
+A deployment that runs back-to-back, or keeps another member in the group, 
never pays this; one
+that starts cold pays it every run. The iterator waits for a partition 
assignment up to
+`assignmentTimeoutMs` rather than mistaking a not-yet-assigned consumer for an 
empty topic.
 
 === Configuration
 
@@ -183,21 +223,35 @@ In addition to the required `fetcherId` / `emitterId` 
(see xref:pipes/iterators.
 
 |`pollDelayMs`
 |`100`
-|Sleep between `poll()` calls when the topic is idle.
+|Timeout passed to each `poll()` call.
 
 |`emitMax`
 |`-1`
 |Maximum tuples to emit. `-1` means unbounded.
 
+|`assignmentTimeoutMs`
+|`30000`
+|How long to wait for the consumer to be assigned a partition before failing. 
A newly
+subscribed consumer returns empty polls while it joins the group; the iterator 
waits for an
+assignment so it cannot mistake that for an empty topic.
+
+|`drainIdleMs`
+|`1000`
+|How long the topic must stay quiet (no records, after assignment) before it 
is treated as
+drained and the iterator finishes. A duration rather than a poll count, so it 
holds however
+short `pollDelayMs` is.
+
 |`groupInitialRebalanceDelayMs`
 |`3000`
-|Initial rebalance delay for the consumer group.
+|*Deprecated and ignored.* This is a broker setting, not a consumer one, so 
Kafka never
+applied it. Use `assignmentTimeoutMs` instead. Still accepted so existing 
configs start;
+scheduled for removal.
 |===
 
 [#kafka-pipeline]
 == Complete Pipeline Example
 
-A Kafka iterator (consuming fetch requests), a filesystem fetcher, and a Kafka 
emitter (publishing parsed results) — the stream-processing shape.
+A Kafka iterator (consuming fetch requests), a filesystem fetcher, and a Kafka 
emitter (publishing parsed results). This end-to-end shape is the reference 
example; see <<iterator-caveats>> before relying on the iterator half.
 
 [source,json]
 ----
@@ -210,4 +264,4 @@ include::example$pipes-kafka-pipeline.json[]
 * The Kafka plugin uses the official `kafka-clients` SDK.
 * The emitter is fire-and-forget at the Tika level; durability is determined 
by Kafka's `acks` and broker replication factor, not by Tika.
 * For exactly-once semantics, set `enableIdempotence: true` (and ensure `acks: 
all`); for transactional semantics, also set `transactionalId`.
-* The iterator's `groupId` controls partition assignment. Set it explicitly in 
production — without one, the consumer receives a transient assignment that 
resets on restart.
+* The iterator's `groupId` controls partition assignment. Set it explicitly — 
without one, the consumer receives a transient assignment that resets on 
restart.
diff --git 
a/tika-integration-tests/tika-pipes-kafka-integration-tests/src/test/java/org/apache/tika/pipes/kafka/tests/TikaPipesKafkaTest.java
 
b/tika-integration-tests/tika-pipes-kafka-integration-tests/src/test/java/org/apache/tika/pipes/kafka/tests/TikaPipesKafkaTest.java
index 1f4a8c1d6a..153a30224b 100644
--- 
a/tika-integration-tests/tika-pipes-kafka-integration-tests/src/test/java/org/apache/tika/pipes/kafka/tests/TikaPipesKafkaTest.java
+++ 
b/tika-integration-tests/tika-pipes-kafka-integration-tests/src/test/java/org/apache/tika/pipes/kafka/tests/TikaPipesKafkaTest.java
@@ -33,8 +33,10 @@ import java.util.Map;
 import java.util.Properties;
 import java.util.Set;
 import java.util.UUID;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 
 import com.fasterxml.jackson.core.type.TypeReference;
@@ -92,7 +94,9 @@ public class TikaPipesKafkaTest {
     private final ObjectMapper objectMapper = new ObjectMapper();
     private final Set<String> waitingFor = new HashSet<>();
     // 
https://java.testcontainers.org/modules/kafka/#using-orgtestcontainerskafkaconfluentkafkacontainer
-    ConfluentKafkaContainer kafka = new 
ConfluentKafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));
+    private static final DockerImageName KAFKA_IMAGE =
+            DockerImageName.parse("confluentinc/cp-kafka:7.4.0");
+    ConfluentKafkaContainer kafka = new ConfluentKafkaContainer(KAFKA_IMAGE);
 
     private void createTestFiles(Path testFileFolderPath) throws Exception {
         Files.createDirectories(testFileFolderPath);
@@ -117,10 +121,30 @@ public class TikaPipesKafkaTest {
 
     @Test
     public void testKafkaPipeIteratorAndEmitter(@TempDir Path pipesDirectory) 
throws Exception {
+        runPipeIteratorAndEmitter(pipesDirectory, 1000);
+    }
+
+    /**
+     * Testcontainers pins the broker's group.initial.rebalance.delay.ms to 0; 
Kafka's own
+     * default is 3000, and tika-pipes leaves the group empty between runs, so 
a real
+     * deployment pays that delay on every run. Restore the stock value here: 
with the default
+     * 100 ms pollDelayMs the iterator used to give up ~30x too early, enqueue 
0 files and
+     * report success (TIKA-4833). Without this the suite cannot see the 
production case at all.
+     */
+    @Test
+    public void testStockBrokerRebalanceDelay(@TempDir Path pipesDirectory) 
throws Exception {
+        kafka.close();
+        kafka = new ConfluentKafkaContainer(KAFKA_IMAGE)
+                .withEnv("KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS", "3000");
+        kafka.start();
+        runPipeIteratorAndEmitter(pipesDirectory, 100);
+    }
+
+    private void runPipeIteratorAndEmitter(Path pipesDirectory, int 
pollDelayMs) throws Exception {
         Path testFileFolderPath = pipesDirectory.resolve("test-files");
         createTestFiles(testFileFolderPath);
 
-        Path tikaConfigPath = getTikaConfig(pipesDirectory, 
testFileFolderPath);
+        Path tikaConfigPath = getTikaConfig(pipesDirectory, 
testFileFolderPath, pollDelayMs);
 
         Properties consumerProps = new Properties();
         consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, 
kafka.getBootstrapServers());
@@ -165,7 +189,10 @@ public class TikaPipesKafkaTest {
             LOG.info("Producer is now complete - sent {}.", numSent);
         }
 
-        es.execute(() -> {
+        // Keep the Future: a bare execute() sent any TikaCLI failure to the 
thread's uncaught
+        // handler, so a broken pipeline surfaced only as the generic "timed 
out waiting for the
+        // emitted docs" below, hiding the real cause (TIKA-4833).
+        Future<?> pipesRun = es.submit(() -> {
             try {
                 TikaCLI.main(new String[]{"-a", "-c", 
tikaConfigPath.toAbsolutePath().toString()});
             } catch (Exception e) {
@@ -179,9 +206,20 @@ public class TikaPipesKafkaTest {
 
         long startNanos = System.nanoTime();
         while (!waitingFor.isEmpty()) {
+            // Surface a tika-pipes failure as itself rather than as a timeout.
+            if (pipesRun.isDone()) {
+                try {
+                    pipesRun.get();
+                } catch (ExecutionException e) {
+                    throw new AssertionError(
+                            "tika-pipes failed before emitting all docs; still 
waiting for " +
+                                    waitingFor.size() + " of " + numDocs, 
e.getCause());
+                }
+            }
             assertFalse(TimeUnit.NANOSECONDS.toMinutes(System.nanoTime() - 
startNanos) > WAIT_FOR_EMITTED_DOCS_TIMEOUT_MINUTES,
                     "Timed out after " + WAIT_FOR_EMITTED_DOCS_TIMEOUT_MINUTES 
+
-                            " minutes waiting for the emitted docs");
+                            " minutes waiting for the emitted docs; still 
waiting for " +
+                            waitingFor.size() + " of " + numDocs);
             try {
                 ConsumerRecords<String, String> records = 
consumer.poll(Duration.ofSeconds(1));
                 for (ConsumerRecord<String, String> record : records) {
@@ -205,7 +243,8 @@ public class TikaPipesKafkaTest {
 
 
     @NotNull
-    private Path getTikaConfig(Path pipesDirectory, Path testFileFolderPath) 
throws Exception {
+    private Path getTikaConfig(Path pipesDirectory, Path testFileFolderPath, 
int pollDelayMs)
+            throws Exception {
         Path tikaConfig = pipesDirectory.resolve("tika-config.json");
 
         Path log4jPropFile = pipesDirectory.resolve("log4j2.xml");
@@ -220,6 +259,7 @@ public class TikaPipesKafkaTest {
         replacements.put("BOOTSTRAP_SERVERS", kafka.getBootstrapServers());
         replacements.put("FETCHER_BASE_PATH", testFileFolderPath);
         replacements.put("PARSE_MODE", ParseMode.RMETA.name());
+        replacements.put("POLL_DELAY_MS", pollDelayMs);
         replacements.put("LOG4J_JVM_ARG", "-Dlog4j.configurationFile=" + 
log4jPropFile.toAbsolutePath());
 
         
JsonConfigHelper.writeConfigFromResource("/kafka/plugins-template.json",
diff --git 
a/tika-integration-tests/tika-pipes-kafka-integration-tests/src/test/resources/kafka/plugins-template.json
 
b/tika-integration-tests/tika-pipes-kafka-integration-tests/src/test/resources/kafka/plugins-template.json
index 1954551646..f2d15245f8 100644
--- 
a/tika-integration-tests/tika-pipes-kafka-integration-tests/src/test/resources/kafka/plugins-template.json
+++ 
b/tika-integration-tests/tika-pipes-kafka-integration-tests/src/test/resources/kafka/plugins-template.json
@@ -83,7 +83,7 @@
       "bootstrapServers": "BOOTSTRAP_SERVERS",
       "groupId": "grpid",
       "autoOffsetReset": "earliest",
-      "pollDelayMs": 1000,
+      "pollDelayMs": "POLL_DELAY_MS",
       "fetcherId": "fsf",
       "emitterId": "ke"
     }
diff --git 
a/tika-pipes/tika-pipes-plugins/tika-pipes-kafka/src/main/java/org/apache/tika/pipes/iterator/kafka/KafkaPipesIterator.java
 
b/tika-pipes/tika-pipes-plugins/tika-pipes-kafka/src/main/java/org/apache/tika/pipes/iterator/kafka/KafkaPipesIterator.java
index 09c9f2b1a7..fd10c27dea 100644
--- 
a/tika-pipes/tika-pipes-plugins/tika-pipes-kafka/src/main/java/org/apache/tika/pipes/iterator/kafka/KafkaPipesIterator.java
+++ 
b/tika-pipes/tika-pipes-plugins/tika-pipes-kafka/src/main/java/org/apache/tika/pipes/iterator/kafka/KafkaPipesIterator.java
@@ -69,7 +69,6 @@ public class KafkaPipesIterator extends PipesIteratorBase {
                 serializerClass(config.getValueSerializer(), 
StringDeserializer.class));
         safePut(props, ConsumerConfig.GROUP_ID_CONFIG, config.getGroupId());
         safePut(props, ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, 
config.getAutoOffsetReset());
-        safePut(props, "group.initial.rebalance.delay.ms", 
config.getGroupInitialRebalanceDelayMs());
 
         consumer = new KafkaConsumer<>(props);
         consumer.subscribe(Arrays.asList(config.getTopic()));
@@ -103,10 +102,29 @@ public class KafkaPipesIterator extends PipesIteratorBase 
{
         long start = System.currentTimeMillis();
         int count = 0;
         int emitMax = config.getEmitMax();
-        ConsumerRecords<String, String> records;
-
-        do {
-            records = 
consumer.poll(Duration.ofMillis(config.getPollDelayMs()));
+        boolean assigned = false;
+        long assignmentDeadline = start + config.getAssignmentTimeoutMs();
+        long idleSince = 0;
+
+        while (true) {
+            ConsumerRecords<String, String> records =
+                    consumer.poll(Duration.ofMillis(config.getPollDelayMs()));
+            // A freshly subscribed consumer spends its first poll(s) joining 
the group, and
+            // those return empty even when the topic has a backlog. Treating 
that as "drained"
+            // silently enqueued nothing and reported success (TIKA-4833), so 
wait for a
+            // partition assignment before an empty poll is allowed to end the 
loop.
+            if (!assigned) {
+                assigned = !consumer.assignment().isEmpty();
+                if (!assigned) {
+                    if (System.currentTimeMillis() > assignmentDeadline) {
+                        throw new TimeoutException(
+                                "Kafka consumer was not assigned a partition 
of topic '" +
+                                        config.getTopic() + "' within " +
+                                        config.getAssignmentTimeoutMs() + " 
ms");
+                    }
+                    continue;
+                }
+            }
             for (ConsumerRecord<String, String> r : records) {
                 long elapsed = System.currentTimeMillis() - start;
                 if (LOGGER.isDebugEnabled()) {
@@ -118,7 +136,23 @@ public class KafkaPipesIterator extends PipesIteratorBase {
                         FetchEmitTuple.ON_PARSE_EXCEPTION.EMIT));
                 ++count;
             }
-        } while ((emitMax < 0 || count < emitMax) && !records.isEmpty());
+            if (emitMax >= 0 && count >= emitMax) {
+                break;
+            }
+            // A single empty poll mid-drain doesn't mean the topic is 
exhausted -- records may
+            // simply not have arrived yet -- so require a continuous quiet 
window. This is a
+            // duration, not a poll count, so it holds however short 
pollDelayMs is.
+            if (records.isEmpty()) {
+                long now = System.currentTimeMillis();
+                if (idleSince == 0) {
+                    idleSince = now;
+                } else if (now - idleSince >= config.getDrainIdleMs()) {
+                    break;
+                }
+            } else {
+                idleSince = 0;
+            }
+        }
 
         long elapsed = System.currentTimeMillis() - start;
         LOGGER.info("Finished enqueuing {} files in {} ms", count, elapsed);
diff --git 
a/tika-pipes/tika-pipes-plugins/tika-pipes-kafka/src/main/java/org/apache/tika/pipes/iterator/kafka/KafkaPipesIteratorConfig.java
 
b/tika-pipes/tika-pipes-plugins/tika-pipes-kafka/src/main/java/org/apache/tika/pipes/iterator/kafka/KafkaPipesIteratorConfig.java
index 63342adbe3..e962a5c568 100644
--- 
a/tika-pipes/tika-pipes-plugins/tika-pipes-kafka/src/main/java/org/apache/tika/pipes/iterator/kafka/KafkaPipesIteratorConfig.java
+++ 
b/tika-pipes/tika-pipes-plugins/tika-pipes-kafka/src/main/java/org/apache/tika/pipes/iterator/kafka/KafkaPipesIteratorConfig.java
@@ -47,7 +47,15 @@ public class KafkaPipesIteratorConfig extends 
PipesIteratorConfig {
     private String autoOffsetReset = "earliest";
     private int pollDelayMs = 100;
     private int emitMax = -1;
+    /**
+     * @deprecated inert -- this is a broker setting, never a consumer one, so 
Kafka has always
+     * ignored it ("supplied but not used yet"). Kept only so existing configs 
still start;
+     * remove in 4.1.0. Use assignmentTimeoutMs to bound waiting for a 
partition assignment.
+     */
+    @Deprecated
     private int groupInitialRebalanceDelayMs = 3000;
+    private int assignmentTimeoutMs = 30000;
+    private int drainIdleMs = 1000;
 
     public String getTopic() {
         return topic;
@@ -81,10 +89,19 @@ public class KafkaPipesIteratorConfig extends 
PipesIteratorConfig {
         return emitMax;
     }
 
+    @Deprecated
     public int getGroupInitialRebalanceDelayMs() {
         return groupInitialRebalanceDelayMs;
     }
 
+    public int getAssignmentTimeoutMs() {
+        return assignmentTimeoutMs;
+    }
+
+    public int getDrainIdleMs() {
+        return drainIdleMs;
+    }
+
     @Override
     public boolean equals(Object o) {
         if (!(o instanceof KafkaPipesIteratorConfig that)) {
@@ -96,6 +113,8 @@ public class KafkaPipesIteratorConfig extends 
PipesIteratorConfig {
         return pollDelayMs == that.pollDelayMs &&
                 emitMax == that.emitMax &&
                 groupInitialRebalanceDelayMs == 
that.groupInitialRebalanceDelayMs &&
+                assignmentTimeoutMs == that.assignmentTimeoutMs &&
+                drainIdleMs == that.drainIdleMs &&
                 Objects.equals(topic, that.topic) &&
                 Objects.equals(bootstrapServers, that.bootstrapServers) &&
                 Objects.equals(keySerializer, that.keySerializer) &&
@@ -116,6 +135,8 @@ public class KafkaPipesIteratorConfig extends 
PipesIteratorConfig {
         result = 31 * result + pollDelayMs;
         result = 31 * result + emitMax;
         result = 31 * result + groupInitialRebalanceDelayMs;
+        result = 31 * result + assignmentTimeoutMs;
+        result = 31 * result + drainIdleMs;
         return result;
     }
 }

Reply via email to