Skip to content

Commit 59e6751

Browse files
authored
TIKA-4815: route tika-grpc through the PipesParser pool (#3022)
1 parent 98df15b commit 59e6751

9 files changed

Lines changed: 617 additions & 12 deletions

File tree

CHANGES.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,15 @@ Release 4.0.0 - ???
130130
after delivering its reply, instead of leaving the client waiting
131131
for a terminal signal that never came (TIKA-4804).
132132

133+
* tika-grpc now routes fetchAndParse through the PipesParser client
134+
pool instead of one shared single-threaded PipesClient: concurrent
135+
calls no longer crash the worker, pipes.numClients and (for the
136+
first time) pipes.useSharedServer take effect, up to numClients
137+
forked worker JVMs instead of one, and pool saturation surfaces
138+
in-band as CLIENT_UNAVAILABLE_WITHIN_MS. An interrupted call
139+
closes its connection and recycles the per-client worker, so a
140+
pooled client cannot go back to the queue dirty (TIKA-4815).
141+
133142

134143
Release 4.0.0-beta-1 - 6/29/2026
135144

tika-grpc/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ to avoid uploading hundreds of megabytes of native libraries and plugin bundles
3737
```
3838
This produces `tika-grpc/target/tika-grpc-<version>.zip` but does **not** deploy it to Nexus.
3939

40+
## Concurrency
41+
42+
`fetchAndParse` runs on a pool of forked worker JVMs sized by `pipes.numClients`
43+
(when unset, derived from host cores, at most 4). A call that cannot get a
44+
worker within `pipes.maxWaitForClientMillis` (default 60s) returns the in-band
45+
status `CLIENT_UNAVAILABLE_WITHIN_MS`: the server is at capacity, not failing.
46+
With `pipes.useSharedServer: true` the workers share one JVM instead.
47+
4048
## Quick Start - Development Mode
4149

4250
The fastest way to run tika-grpc in development mode with plugin hot-reloading:

tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,9 @@
6868
import org.apache.tika.pipes.api.fetcher.Fetcher;
6969
import org.apache.tika.pipes.api.fetcher.FetcherFactory;
7070
import org.apache.tika.pipes.api.pipesiterator.PipesIteratorFactory;
71-
import org.apache.tika.pipes.core.PipesClient;
7271
import org.apache.tika.pipes.core.PipesConfig;
72+
import org.apache.tika.pipes.core.PipesException;
73+
import org.apache.tika.pipes.core.PipesParser;
7374
import org.apache.tika.pipes.core.config.ConfigStore;
7475
import org.apache.tika.pipes.core.config.ConfigStoreFactory;
7576
import org.apache.tika.pipes.core.fetcher.FetcherManager;
@@ -88,7 +89,7 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase {
8889

8990
PipesConfig pipesConfig;
9091
TikaGrpcConfig tikaGrpcConfig;
91-
PipesClient pipesClient;
92+
PipesParser pipesParser;
9293
FetcherManager fetcherManager;
9394
ConfigStore configStore;
9495
Path tikaConfigPath;
@@ -120,7 +121,8 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase {
120121
// modifications) are off unless explicitly enabled in the "grpc" section.
121122
tikaGrpcConfig = TikaGrpcConfig.load(tikaJsonConfig);
122123

123-
pipesClient = new PipesClient(pipesConfig, configPath);
124+
// PipesClient is single-threaded; the pool admits pipes.numClients at a time.
125+
pipesParser = PipesParser.load(tikaJsonConfig, pipesConfig, configPath);
124126

125127
try {
126128
if (pluginRootsOverride != null && !pluginRootsOverride.trim().isEmpty()) {
@@ -304,7 +306,7 @@ private void fetchAndParseImpl(FetchAndParseRequest request,
304306
contextNode.fields().forEachRemaining(entry ->
305307
parseContext.setJsonConfig(entry.getKey(), entry.getValue().toString()));
306308
}
307-
PipesResult pipesResult = pipesClient.process(new FetchEmitTuple(request.getFetchKey(), new FetchKey(fetcher.getExtensionConfig().id(), request.getFetchKey()),
309+
PipesResult pipesResult = pipesParser.parse(new FetchEmitTuple(request.getFetchKey(), new FetchKey(fetcher.getExtensionConfig().id(), request.getFetchKey()),
308310
new EmitKey(), tikaMetadata, parseContext, FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP));
309311
FetchAndParseReply.Builder fetchReplyBuilder =
310312
FetchAndParseReply.newBuilder()
@@ -324,7 +326,7 @@ private void fetchAndParseImpl(FetchAndParseRequest request,
324326
}
325327
}
326328
responseObserver.onNext(fetchReplyBuilder.build());
327-
} catch (IOException e) {
329+
} catch (IOException | PipesException e) {
328330
throw new RuntimeException(e);
329331
} catch (InterruptedException e) {
330332
Thread.currentThread().interrupt();
@@ -630,17 +632,17 @@ public void shutdown() {
630632
}
631633

632634
/**
633-
* Close the pipe client, to be called after TikaGrpcServer has shut down.
635+
* Close the pipes parser, to be called after TikaGrpcServer has shut down.
634636
*/
635637
void postShutdown() {
636-
if (pipesClient != null) {
637-
LOG.info("Shutting down the pipes client");
638+
if (pipesParser != null) {
639+
LOG.info("Shutting down the pipes parser");
638640
try {
639-
pipesClient.close();
641+
pipesParser.close();
640642
} catch (IOException e) {
641-
LOG.error("Error closing the pipes client", e);
643+
LOG.error("Error closing the pipes parser", e);
642644
} finally {
643-
pipesClient = null;
645+
pipesParser = null;
644646
}
645647
}
646648
}

tika-grpc/src/main/proto/tika.proto

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ message FetchAndParseReply {
113113
string fetch_key = 1;
114114
// Metadata fields from the parse output.
115115
map<string, string> fields = 2;
116-
// The status from the message. See javadoc for org.apache.tika.pipes.PipesResult.STATUS for the list of status.
116+
// The status from the message. See javadoc for
117+
// org.apache.tika.pipes.api.PipesResult.RESULT_STATUS for the list of statuses.
117118
string status = 3;
118119
// If there was an error, this will contain the error message.
119120
string error_message = 4;
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.pipes.grpc;
18+
19+
import static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertFalse;
21+
import static org.junit.jupiter.api.Assertions.assertTrue;
22+
23+
import java.io.File;
24+
import java.nio.charset.StandardCharsets;
25+
import java.nio.file.Files;
26+
import java.nio.file.Path;
27+
import java.nio.file.Paths;
28+
import java.time.Duration;
29+
import java.util.ArrayList;
30+
import java.util.Collections;
31+
import java.util.HashMap;
32+
import java.util.List;
33+
import java.util.Map;
34+
import java.util.UUID;
35+
import java.util.concurrent.Callable;
36+
import java.util.concurrent.CyclicBarrier;
37+
import java.util.concurrent.ExecutorService;
38+
import java.util.concurrent.Executors;
39+
import java.util.concurrent.Future;
40+
import java.util.concurrent.TimeUnit;
41+
42+
import com.asarkar.grpc.test.GrpcCleanupExtension;
43+
import com.asarkar.grpc.test.Resources;
44+
import com.fasterxml.jackson.databind.ObjectMapper;
45+
import com.fasterxml.jackson.databind.node.ObjectNode;
46+
import io.grpc.ManagedChannel;
47+
import io.grpc.Server;
48+
import io.grpc.inprocess.InProcessChannelBuilder;
49+
import io.grpc.inprocess.InProcessServerBuilder;
50+
import org.apache.commons.io.FileUtils;
51+
import org.junit.jupiter.api.Test;
52+
import org.junit.jupiter.api.extension.ExtendWith;
53+
54+
import org.apache.tika.FetchAndParseReply;
55+
import org.apache.tika.FetchAndParseRequest;
56+
import org.apache.tika.TikaGrpc;
57+
import org.apache.tika.pipes.api.PipesResult;
58+
import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher;
59+
import org.apache.tika.serialization.config.JsonConfigHelper;
60+
61+
/**
62+
* Concurrent fetchAndParse against a server built WITHOUT directExecutor(),
63+
* like the production server: each call runs on its own handler thread, so
64+
* these tests exercise the pipes layer under real handler concurrency.
65+
*/
66+
@ExtendWith(GrpcCleanupExtension.class)
67+
public class TikaGrpcConcurrencyTest {
68+
69+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
70+
71+
// The fetcher must come from the config file: one saved at runtime through
72+
// saveFetcher is not visible to the forked worker.
73+
private static final String FETCHER_ID = "nick1:is:cool:super/" + FileSystemFetcher.class;
74+
75+
/**
76+
* All concurrent calls must parse, and each reply must carry its own
77+
* document. The barrier guarantees the calls overlap; the per-request
78+
* marker catches replies wired to the wrong request even when every
79+
* status says success.
80+
*/
81+
@Test
82+
public void concurrentCallsAllParseTheirOwnDocument(Resources resources) throws Exception {
83+
runConcurrentBurst(resources, writeConfig(null, null, null));
84+
}
85+
86+
/**
87+
* The same burst with pipes.useSharedServer=true. This change makes shared
88+
* mode reachable from tika-grpc for the first time (the old single-client
89+
* constructor always forced per-client mode), so prove the wiring end to
90+
* end: one shared worker JVM, two connections, four calls.
91+
*/
92+
@Test
93+
public void sharedServerModeParsesConcurrently(Resources resources) throws Exception {
94+
runConcurrentBurst(resources, writeConfig(null, null, Boolean.TRUE));
95+
}
96+
97+
private void runConcurrentBurst(Resources resources, Path config) throws Exception {
98+
int concurrency = 4;
99+
TikaGrpcServerImpl service = new TikaGrpcServerImpl(config.toAbsolutePath().toString());
100+
List<File> testFiles = new ArrayList<>();
101+
ExecutorService pool = Executors.newFixedThreadPool(concurrency);
102+
try {
103+
TikaGrpc.TikaBlockingStub stub = startServer(resources, service);
104+
warmUp(stub, testFiles);
105+
106+
CyclicBarrier barrier = new CyclicBarrier(concurrency);
107+
List<Callable<FetchAndParseReply>> calls = new ArrayList<>();
108+
List<String> fetchKeys = new ArrayList<>();
109+
List<String> markers = new ArrayList<>();
110+
for (int i = 0; i < concurrency; i++) {
111+
String marker = "tika4815-marker-" + i + "-" + UUID.randomUUID();
112+
String fetchKey = "tika4815-doc-" + i + "-" + UUID.randomUUID() + ".html";
113+
writeDoc(testFiles, fetchKey, marker);
114+
fetchKeys.add(fetchKey);
115+
markers.add(marker);
116+
calls.add(() -> {
117+
barrier.await(30, TimeUnit.SECONDS);
118+
return stub.fetchAndParse(FetchAndParseRequest.newBuilder()
119+
.setFetcherId(FETCHER_ID)
120+
.setFetchKey(fetchKey)
121+
.build());
122+
});
123+
}
124+
List<Future<FetchAndParseReply>> futures =
125+
pool.invokeAll(calls, 120, TimeUnit.SECONDS);
126+
for (int i = 0; i < concurrency; i++) {
127+
Future<FetchAndParseReply> future = futures.get(i);
128+
assertFalse(future.isCancelled(),
129+
"call " + i + " did not finish within the time budget");
130+
FetchAndParseReply reply = future.get();
131+
assertEquals(fetchKeys.get(i), reply.getFetchKey());
132+
assertEquals(PipesResult.RESULT_STATUS.PARSE_SUCCESS.name(), reply.getStatus(),
133+
"call " + i + " must parse; error: " + reply.getErrorMessage());
134+
String marker = markers.get(i);
135+
assertTrue(reply.getFieldsMap().values().stream()
136+
.anyMatch(v -> v.contains(marker)),
137+
"call " + i + " must carry its own document, not another call's");
138+
}
139+
} finally {
140+
pool.shutdownNow();
141+
service.postShutdown();
142+
cleanUp(config, testFiles);
143+
}
144+
}
145+
146+
/**
147+
* With one client and a zero wait, two overlapping calls must split into
148+
* one parse and one in-band CLIENT_UNAVAILABLE_WITHIN_MS, the same way
149+
* every other worker outcome already reaches the caller.
150+
* <p>
151+
* Deliberately not warmed up: the winning call holds the only client for
152+
* the whole worker fork, seconds against the loser's zero-wait admission
153+
* check. A warm worker would shrink that window to one small parse.
154+
*/
155+
@Test
156+
public void saturationSurfacesInBand(Resources resources) throws Exception {
157+
Path config = writeConfig(1, 0L, null);
158+
TikaGrpcServerImpl service = new TikaGrpcServerImpl(config.toAbsolutePath().toString());
159+
List<File> testFiles = new ArrayList<>();
160+
ExecutorService pool = Executors.newFixedThreadPool(2);
161+
try {
162+
TikaGrpc.TikaBlockingStub stub = startServer(resources, service);
163+
164+
CyclicBarrier barrier = new CyclicBarrier(2);
165+
List<Callable<FetchAndParseReply>> calls = new ArrayList<>();
166+
for (int i = 0; i < 2; i++) {
167+
String fetchKey = "tika4815-sat-" + i + "-" + UUID.randomUUID() + ".html";
168+
writeDoc(testFiles, fetchKey, "saturation " + i);
169+
calls.add(() -> {
170+
barrier.await(30, TimeUnit.SECONDS);
171+
return stub.fetchAndParse(FetchAndParseRequest.newBuilder()
172+
.setFetcherId(FETCHER_ID)
173+
.setFetchKey(fetchKey)
174+
.build());
175+
});
176+
}
177+
List<String> statuses = new ArrayList<>();
178+
List<Future<FetchAndParseReply>> futures =
179+
pool.invokeAll(calls, 120, TimeUnit.SECONDS);
180+
for (int i = 0; i < futures.size(); i++) {
181+
Future<FetchAndParseReply> future = futures.get(i);
182+
assertFalse(future.isCancelled(),
183+
"call " + i + " did not finish within the time budget");
184+
statuses.add(future.get().getStatus());
185+
}
186+
Collections.sort(statuses);
187+
assertEquals(List.of(
188+
PipesResult.RESULT_STATUS.CLIENT_UNAVAILABLE_WITHIN_MS.name(),
189+
PipesResult.RESULT_STATUS.PARSE_SUCCESS.name()),
190+
statuses);
191+
} finally {
192+
pool.shutdownNow();
193+
service.postShutdown();
194+
cleanUp(config, testFiles);
195+
}
196+
}
197+
198+
private static TikaGrpc.TikaBlockingStub startServer(Resources resources,
199+
TikaGrpcServerImpl service) throws Exception {
200+
String serverName = InProcessServerBuilder.generateName();
201+
// NOTE: no directExecutor() anywhere -- the production server
202+
// (Grpc.newServerBuilderForPort) also dispatches on a thread pool.
203+
Server server = InProcessServerBuilder.forName(serverName)
204+
.addService(service)
205+
.build()
206+
.start();
207+
resources.register(server, Duration.ofSeconds(30));
208+
ManagedChannel channel = InProcessChannelBuilder.forName(serverName).build();
209+
resources.register(channel, Duration.ofSeconds(30));
210+
return TikaGrpc.newBlockingStub(channel);
211+
}
212+
213+
/**
214+
* One sequential call first, so the worker is already up and the burst
215+
* cannot be blamed on cold start.
216+
*/
217+
private static void warmUp(TikaGrpc.TikaBlockingStub stub, List<File> testFiles)
218+
throws Exception {
219+
String fetchKey = "tika4815-warmup-" + UUID.randomUUID() + ".html";
220+
writeDoc(testFiles, fetchKey, "warmup");
221+
FetchAndParseReply reply = stub.fetchAndParse(FetchAndParseRequest.newBuilder()
222+
.setFetcherId(FETCHER_ID)
223+
.setFetchKey(fetchKey)
224+
.build());
225+
assertEquals(PipesResult.RESULT_STATUS.PARSE_SUCCESS.name(), reply.getStatus(),
226+
"the warmup fixture must parse, or this test proves nothing");
227+
}
228+
229+
private static void writeDoc(List<File> testFiles, String fetchKey, String marker)
230+
throws Exception {
231+
File doc = new File("target", fetchKey);
232+
synchronized (testFiles) {
233+
testFiles.add(doc);
234+
}
235+
FileUtils.writeStringToFile(doc,
236+
"<html><head><title>" + marker + "</title></head><body>" + marker
237+
+ "</body></html>", StandardCharsets.UTF_8);
238+
}
239+
240+
private static Path writeConfig(Integer numClients, Long maxWaitForClientMillis,
241+
Boolean useSharedServer) throws Exception {
242+
Path config = Paths.get("target", "tika4815-config-" + UUID.randomUUID() + ".json");
243+
Map<String, Object> replacements = new HashMap<>();
244+
replacements.put("JAVA_PATH", Paths.get(System.getProperty("java.home"), "bin", "java"));
245+
replacements.put("FETCHER_BASE_PATH", Paths.get("target").toAbsolutePath());
246+
replacements.put("PLUGIN_ROOTS", Paths.get("target").toAbsolutePath().resolve("plugins"));
247+
JsonConfigHelper.writeConfigFromResource("/tika-pipes-test-config.json",
248+
TikaGrpcConcurrencyTest.class, replacements, config);
249+
if (numClients != null || maxWaitForClientMillis != null || useSharedServer != null) {
250+
ObjectNode root = (ObjectNode) OBJECT_MAPPER.readTree(config.toFile());
251+
ObjectNode pipes = (ObjectNode) root.get("pipes");
252+
if (numClients != null) {
253+
pipes.put("numClients", numClients);
254+
}
255+
if (maxWaitForClientMillis != null) {
256+
pipes.put("maxWaitForClientMillis", maxWaitForClientMillis);
257+
}
258+
if (useSharedServer != null) {
259+
pipes.put("useSharedServer", useSharedServer);
260+
}
261+
Files.writeString(config, OBJECT_MAPPER.writerWithDefaultPrettyPrinter()
262+
.writeValueAsString(root), StandardCharsets.UTF_8);
263+
}
264+
return config;
265+
}
266+
267+
private static void cleanUp(Path config, List<File> testFiles) throws Exception {
268+
Files.deleteIfExists(config);
269+
for (File f : testFiles) {
270+
FileUtils.deleteQuietly(f);
271+
}
272+
}
273+
}

tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,12 @@ public void markServerForRestart() {
288288
pendingRestart = true;
289289
}
290290

291+
@Override
292+
public void connectionAbandoned() {
293+
LOG.info("clientId={}: connection abandoned mid-request, recycling the worker", clientId);
294+
pendingRestart = true;
295+
}
296+
291297
@Override
292298
public int handleCrashAndGetExitCode() {
293299
pendingRestart = true;

0 commit comments

Comments
 (0)