Skip to content

Commit 50d906a

Browse files
authored
TIKA-4833 -- fix Kafka iterator (#3048)
1 parent 8c699d8 commit 50d906a

6 files changed

Lines changed: 180 additions & 22 deletions

File tree

CHANGES.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
Release 4.1.0 - unreleased
22

3+
* The Kafka pipes iterator no longer stops at the first empty poll. A newly
4+
subscribed consumer spends its first poll(s) joining the group and returns
5+
empty even when the topic has a backlog, so the iterator could enqueue zero
6+
files and report success. It now waits for a partition assignment (bounded
7+
by the new assignmentTimeoutMs, default 30s) and requires a continuous quiet
8+
window (drainIdleMs, default 1s) before concluding the topic is drained.
9+
groupInitialRebalanceDelayMs is deprecated and no longer sent to the
10+
consumer: it is a broker setting that Kafka has always ignored (TIKA-4833).
11+
312
* Pipes IPC: carry inline document bytes as a raw binary field beside the
413
tuple in the request envelope -- never inside the tuple or its
514
ParseContext -- and disable Smile's 7-bit binary encoding. Tuple JSON

docs/modules/ROOT/pages/pipes/plugins/kafka.adoc

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121

2222
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).
2323

24+
The two halves have different standing. The *emitter* is a plain producer and a good fit: Tika
25+
parses, results stream to a topic for downstream indexing. The *iterator* is a worked example
26+
whose suitability depends on your parse latency -- see <<iterator-caveats>> before building on
27+
it.
28+
2429
[cols="2,1,3"]
2530
|===
2631
|Interface |Component name |Class
@@ -146,12 +151,47 @@ applies. The Default column below is therefore the Kafka client's default.
146151
[#kafka-iterator]
147152
== Kafka Iterator (`kafka-pipes-iterator`)
148153

149-
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.
150-
151-
[source,json]
152-
----
153-
include::example$pipes-kafka-iterator.json[]
154-
----
154+
[WARNING]
155+
.Reference example -- check that your parse latency suits it
156+
====
157+
Kafka's consumer model assumes bounded, roughly uniform per-message processing time, so how
158+
well this component works depends on *how long your documents take to parse*. Short, predictable
159+
parses fit that assumption. Long-running parses do not: pairing Kafka with documents that take
160+
minutes to an hour -- OCR'd PDFs, large archives, anything near the one-hour default total-task
161+
timeout -- works against the grain of the offset model, and the caveats below stop being
162+
theoretical.
163+
164+
It is kept as a worked example rather than a supported production integration. If your workload
165+
has a long latency tail, consider driving tika-server or tika-grpc from your own consumer, where
166+
you control acknowledgement and retry, and use the <<kafka-emitter,Kafka emitter>> to publish
167+
results.
168+
====
169+
170+
[#iterator-caveats]
171+
=== Things to know before building on it
172+
173+
*Head-of-line blocking, in proportion to your latency spread.* A partition is consumed in order
174+
by one member of the group. Even with `numClients` workers parsing in parallel, correct offset
175+
handling can only advance the commit watermark to the lowest un-acknowledged offset, so one slow
176+
document holds up its partition's progress. With short, uniform parses this is barely
177+
noticeable; with a long tail, one document can stall a partition for as long as it parses.
178+
This follows from Kafka's offset model rather than from a setting.
179+
180+
*At-most-once delivery.* `enable.auto.commit` is left at Kafka's default of `true`, so offsets
181+
are committed on a timer as soon as records are *polled* -- before Tika has parsed or emitted
182+
them. A crash, OOM or failed emit in between loses those documents silently. Committing after a
183+
successful emit is not currently possible: the iterator pushes tuples onto a queue and receives
184+
no completion signal back.
185+
186+
*It drains and exits; it does not stream.* The iterator enqueues what is on the topic and then
187+
finishes, because tika-pipes' iterator contract is finite. A quiet period ends the run (see
188+
`drainIdleMs`). It suits a periodic batch drain, not a long-running consumer.
189+
190+
*Give the consumer room to join.* A stock Kafka broker applies
191+
`group.initial.rebalance.delay.ms` (default 3000) to the first member joining an *empty* group.
192+
A deployment that runs back-to-back, or keeps another member in the group, never pays this; one
193+
that starts cold pays it every run. The iterator waits for a partition assignment up to
194+
`assignmentTimeoutMs` rather than mistaking a not-yet-assigned consumer for an empty topic.
155195

156196
=== Configuration
157197

@@ -183,21 +223,35 @@ In addition to the required `fetcherId` / `emitterId` (see xref:pipes/iterators.
183223

184224
|`pollDelayMs`
185225
|`100`
186-
|Sleep between `poll()` calls when the topic is idle.
226+
|Timeout passed to each `poll()` call.
187227

188228
|`emitMax`
189229
|`-1`
190230
|Maximum tuples to emit. `-1` means unbounded.
191231

232+
|`assignmentTimeoutMs`
233+
|`30000`
234+
|How long to wait for the consumer to be assigned a partition before failing. A newly
235+
subscribed consumer returns empty polls while it joins the group; the iterator waits for an
236+
assignment so it cannot mistake that for an empty topic.
237+
238+
|`drainIdleMs`
239+
|`1000`
240+
|How long the topic must stay quiet (no records, after assignment) before it is treated as
241+
drained and the iterator finishes. A duration rather than a poll count, so it holds however
242+
short `pollDelayMs` is.
243+
192244
|`groupInitialRebalanceDelayMs`
193245
|`3000`
194-
|Initial rebalance delay for the consumer group.
246+
|*Deprecated and ignored.* This is a broker setting, not a consumer one, so Kafka never
247+
applied it. Use `assignmentTimeoutMs` instead. Still accepted so existing configs start;
248+
scheduled for removal.
195249
|===
196250

197251
[#kafka-pipeline]
198252
== Complete Pipeline Example
199253

200-
A Kafka iterator (consuming fetch requests), a filesystem fetcher, and a Kafka emitter (publishing parsed results)the stream-processing shape.
254+
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.
201255

202256
[source,json]
203257
----
@@ -210,4 +264,4 @@ include::example$pipes-kafka-pipeline.json[]
210264
* The Kafka plugin uses the official `kafka-clients` SDK.
211265
* The emitter is fire-and-forget at the Tika level; durability is determined by Kafka's `acks` and broker replication factor, not by Tika.
212266
* For exactly-once semantics, set `enableIdempotence: true` (and ensure `acks: all`); for transactional semantics, also set `transactionalId`.
213-
* The iterator's `groupId` controls partition assignment. Set it explicitly in production — without one, the consumer receives a transient assignment that resets on restart.
267+
* The iterator's `groupId` controls partition assignment. Set it explicitly — without one, the consumer receives a transient assignment that resets on restart.

tika-integration-tests/tika-pipes-kafka-integration-tests/src/test/java/org/apache/tika/pipes/kafka/tests/TikaPipesKafkaTest.java

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@
3333
import java.util.Properties;
3434
import java.util.Set;
3535
import java.util.UUID;
36+
import java.util.concurrent.ExecutionException;
3637
import java.util.concurrent.ExecutorService;
3738
import java.util.concurrent.Executors;
39+
import java.util.concurrent.Future;
3840
import java.util.concurrent.TimeUnit;
3941

4042
import com.fasterxml.jackson.core.type.TypeReference;
@@ -92,7 +94,9 @@ public static void setUp() {
9294
private final ObjectMapper objectMapper = new ObjectMapper();
9395
private final Set<String> waitingFor = new HashSet<>();
9496
// https://java.testcontainers.org/modules/kafka/#using-orgtestcontainerskafkaconfluentkafkacontainer
95-
ConfluentKafkaContainer kafka = new ConfluentKafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));
97+
private static final DockerImageName KAFKA_IMAGE =
98+
DockerImageName.parse("confluentinc/cp-kafka:7.4.0");
99+
ConfluentKafkaContainer kafka = new ConfluentKafkaContainer(KAFKA_IMAGE);
96100

97101
private void createTestFiles(Path testFileFolderPath) throws Exception {
98102
Files.createDirectories(testFileFolderPath);
@@ -117,10 +121,30 @@ public void after() {
117121

118122
@Test
119123
public void testKafkaPipeIteratorAndEmitter(@TempDir Path pipesDirectory) throws Exception {
124+
runPipeIteratorAndEmitter(pipesDirectory, 1000);
125+
}
126+
127+
/**
128+
* Testcontainers pins the broker's group.initial.rebalance.delay.ms to 0; Kafka's own
129+
* default is 3000, and tika-pipes leaves the group empty between runs, so a real
130+
* deployment pays that delay on every run. Restore the stock value here: with the default
131+
* 100 ms pollDelayMs the iterator used to give up ~30x too early, enqueue 0 files and
132+
* report success (TIKA-4833). Without this the suite cannot see the production case at all.
133+
*/
134+
@Test
135+
public void testStockBrokerRebalanceDelay(@TempDir Path pipesDirectory) throws Exception {
136+
kafka.close();
137+
kafka = new ConfluentKafkaContainer(KAFKA_IMAGE)
138+
.withEnv("KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS", "3000");
139+
kafka.start();
140+
runPipeIteratorAndEmitter(pipesDirectory, 100);
141+
}
142+
143+
private void runPipeIteratorAndEmitter(Path pipesDirectory, int pollDelayMs) throws Exception {
120144
Path testFileFolderPath = pipesDirectory.resolve("test-files");
121145
createTestFiles(testFileFolderPath);
122146

123-
Path tikaConfigPath = getTikaConfig(pipesDirectory, testFileFolderPath);
147+
Path tikaConfigPath = getTikaConfig(pipesDirectory, testFileFolderPath, pollDelayMs);
124148

125149
Properties consumerProps = new Properties();
126150
consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
@@ -165,7 +189,10 @@ public void testKafkaPipeIteratorAndEmitter(@TempDir Path pipesDirectory) throws
165189
LOG.info("Producer is now complete - sent {}.", numSent);
166190
}
167191

168-
es.execute(() -> {
192+
// Keep the Future: a bare execute() sent any TikaCLI failure to the thread's uncaught
193+
// handler, so a broken pipeline surfaced only as the generic "timed out waiting for the
194+
// emitted docs" below, hiding the real cause (TIKA-4833).
195+
Future<?> pipesRun = es.submit(() -> {
169196
try {
170197
TikaCLI.main(new String[]{"-a", "-c", tikaConfigPath.toAbsolutePath().toString()});
171198
} catch (Exception e) {
@@ -179,9 +206,20 @@ public void testKafkaPipeIteratorAndEmitter(@TempDir Path pipesDirectory) throws
179206

180207
long startNanos = System.nanoTime();
181208
while (!waitingFor.isEmpty()) {
209+
// Surface a tika-pipes failure as itself rather than as a timeout.
210+
if (pipesRun.isDone()) {
211+
try {
212+
pipesRun.get();
213+
} catch (ExecutionException e) {
214+
throw new AssertionError(
215+
"tika-pipes failed before emitting all docs; still waiting for " +
216+
waitingFor.size() + " of " + numDocs, e.getCause());
217+
}
218+
}
182219
assertFalse(TimeUnit.NANOSECONDS.toMinutes(System.nanoTime() - startNanos) > WAIT_FOR_EMITTED_DOCS_TIMEOUT_MINUTES,
183220
"Timed out after " + WAIT_FOR_EMITTED_DOCS_TIMEOUT_MINUTES +
184-
" minutes waiting for the emitted docs");
221+
" minutes waiting for the emitted docs; still waiting for " +
222+
waitingFor.size() + " of " + numDocs);
185223
try {
186224
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
187225
for (ConsumerRecord<String, String> record : records) {
@@ -205,7 +243,8 @@ public void testKafkaPipeIteratorAndEmitter(@TempDir Path pipesDirectory) throws
205243

206244

207245
@NotNull
208-
private Path getTikaConfig(Path pipesDirectory, Path testFileFolderPath) throws Exception {
246+
private Path getTikaConfig(Path pipesDirectory, Path testFileFolderPath, int pollDelayMs)
247+
throws Exception {
209248
Path tikaConfig = pipesDirectory.resolve("tika-config.json");
210249

211250
Path log4jPropFile = pipesDirectory.resolve("log4j2.xml");
@@ -220,6 +259,7 @@ private Path getTikaConfig(Path pipesDirectory, Path testFileFolderPath) throws
220259
replacements.put("BOOTSTRAP_SERVERS", kafka.getBootstrapServers());
221260
replacements.put("FETCHER_BASE_PATH", testFileFolderPath);
222261
replacements.put("PARSE_MODE", ParseMode.RMETA.name());
262+
replacements.put("POLL_DELAY_MS", pollDelayMs);
223263
replacements.put("LOG4J_JVM_ARG", "-Dlog4j.configurationFile=" + log4jPropFile.toAbsolutePath());
224264

225265
JsonConfigHelper.writeConfigFromResource("/kafka/plugins-template.json",

tika-integration-tests/tika-pipes-kafka-integration-tests/src/test/resources/kafka/plugins-template.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
"bootstrapServers": "BOOTSTRAP_SERVERS",
8484
"groupId": "grpid",
8585
"autoOffsetReset": "earliest",
86-
"pollDelayMs": 1000,
86+
"pollDelayMs": "POLL_DELAY_MS",
8787
"fetcherId": "fsf",
8888
"emitterId": "ke"
8989
}

tika-pipes/tika-pipes-plugins/tika-pipes-kafka/src/main/java/org/apache/tika/pipes/iterator/kafka/KafkaPipesIterator.java

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ private void configure() throws IOException, TikaConfigException {
6969
serializerClass(config.getValueSerializer(), StringDeserializer.class));
7070
safePut(props, ConsumerConfig.GROUP_ID_CONFIG, config.getGroupId());
7171
safePut(props, ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, config.getAutoOffsetReset());
72-
safePut(props, "group.initial.rebalance.delay.ms", config.getGroupInitialRebalanceDelayMs());
7372

7473
consumer = new KafkaConsumer<>(props);
7574
consumer.subscribe(Arrays.asList(config.getTopic()));
@@ -103,10 +102,29 @@ protected void enqueue() throws InterruptedException, TimeoutException {
103102
long start = System.currentTimeMillis();
104103
int count = 0;
105104
int emitMax = config.getEmitMax();
106-
ConsumerRecords<String, String> records;
107-
108-
do {
109-
records = consumer.poll(Duration.ofMillis(config.getPollDelayMs()));
105+
boolean assigned = false;
106+
long assignmentDeadline = start + config.getAssignmentTimeoutMs();
107+
long idleSince = 0;
108+
109+
while (true) {
110+
ConsumerRecords<String, String> records =
111+
consumer.poll(Duration.ofMillis(config.getPollDelayMs()));
112+
// A freshly subscribed consumer spends its first poll(s) joining the group, and
113+
// those return empty even when the topic has a backlog. Treating that as "drained"
114+
// silently enqueued nothing and reported success (TIKA-4833), so wait for a
115+
// partition assignment before an empty poll is allowed to end the loop.
116+
if (!assigned) {
117+
assigned = !consumer.assignment().isEmpty();
118+
if (!assigned) {
119+
if (System.currentTimeMillis() > assignmentDeadline) {
120+
throw new TimeoutException(
121+
"Kafka consumer was not assigned a partition of topic '" +
122+
config.getTopic() + "' within " +
123+
config.getAssignmentTimeoutMs() + " ms");
124+
}
125+
continue;
126+
}
127+
}
110128
for (ConsumerRecord<String, String> r : records) {
111129
long elapsed = System.currentTimeMillis() - start;
112130
if (LOGGER.isDebugEnabled()) {
@@ -118,7 +136,23 @@ protected void enqueue() throws InterruptedException, TimeoutException {
118136
FetchEmitTuple.ON_PARSE_EXCEPTION.EMIT));
119137
++count;
120138
}
121-
} while ((emitMax < 0 || count < emitMax) && !records.isEmpty());
139+
if (emitMax >= 0 && count >= emitMax) {
140+
break;
141+
}
142+
// A single empty poll mid-drain doesn't mean the topic is exhausted -- records may
143+
// simply not have arrived yet -- so require a continuous quiet window. This is a
144+
// duration, not a poll count, so it holds however short pollDelayMs is.
145+
if (records.isEmpty()) {
146+
long now = System.currentTimeMillis();
147+
if (idleSince == 0) {
148+
idleSince = now;
149+
} else if (now - idleSince >= config.getDrainIdleMs()) {
150+
break;
151+
}
152+
} else {
153+
idleSince = 0;
154+
}
155+
}
122156

123157
long elapsed = System.currentTimeMillis() - start;
124158
LOGGER.info("Finished enqueuing {} files in {} ms", count, elapsed);

tika-pipes/tika-pipes-plugins/tika-pipes-kafka/src/main/java/org/apache/tika/pipes/iterator/kafka/KafkaPipesIteratorConfig.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,15 @@ public static KafkaPipesIteratorConfig load(final String json)
4747
private String autoOffsetReset = "earliest";
4848
private int pollDelayMs = 100;
4949
private int emitMax = -1;
50+
/**
51+
* @deprecated inert -- this is a broker setting, never a consumer one, so Kafka has always
52+
* ignored it ("supplied but not used yet"). Kept only so existing configs still start;
53+
* remove in 4.1.0. Use assignmentTimeoutMs to bound waiting for a partition assignment.
54+
*/
55+
@Deprecated
5056
private int groupInitialRebalanceDelayMs = 3000;
57+
private int assignmentTimeoutMs = 30000;
58+
private int drainIdleMs = 1000;
5159

5260
public String getTopic() {
5361
return topic;
@@ -81,10 +89,19 @@ public int getEmitMax() {
8189
return emitMax;
8290
}
8391

92+
@Deprecated
8493
public int getGroupInitialRebalanceDelayMs() {
8594
return groupInitialRebalanceDelayMs;
8695
}
8796

97+
public int getAssignmentTimeoutMs() {
98+
return assignmentTimeoutMs;
99+
}
100+
101+
public int getDrainIdleMs() {
102+
return drainIdleMs;
103+
}
104+
88105
@Override
89106
public boolean equals(Object o) {
90107
if (!(o instanceof KafkaPipesIteratorConfig that)) {
@@ -96,6 +113,8 @@ public boolean equals(Object o) {
96113
return pollDelayMs == that.pollDelayMs &&
97114
emitMax == that.emitMax &&
98115
groupInitialRebalanceDelayMs == that.groupInitialRebalanceDelayMs &&
116+
assignmentTimeoutMs == that.assignmentTimeoutMs &&
117+
drainIdleMs == that.drainIdleMs &&
99118
Objects.equals(topic, that.topic) &&
100119
Objects.equals(bootstrapServers, that.bootstrapServers) &&
101120
Objects.equals(keySerializer, that.keySerializer) &&
@@ -116,6 +135,8 @@ public int hashCode() {
116135
result = 31 * result + pollDelayMs;
117136
result = 31 * result + emitMax;
118137
result = 31 * result + groupInitialRebalanceDelayMs;
138+
result = 31 * result + assignmentTimeoutMs;
139+
result = 31 * result + drainIdleMs;
119140
return result;
120141
}
121142
}

0 commit comments

Comments
 (0)