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 59e6751d44 TIKA-4815: route tika-grpc through the PipesParser pool 
(#3022)
59e6751d44 is described below

commit 59e6751d441b5aa03dd956b3d6a1802348cd6c73
Author: Davide Polato <[email protected]>
AuthorDate: Fri Aug 14 19:58:37 2026 +0200

    TIKA-4815: route tika-grpc through the PipesParser pool (#3022)
---
 CHANGES.txt                                        |   9 +
 tika-grpc/README.md                                |   8 +
 .../apache/tika/pipes/grpc/TikaGrpcServerImpl.java |  24 +-
 tika-grpc/src/main/proto/tika.proto                |   3 +-
 .../tika/pipes/grpc/TikaGrpcConcurrencyTest.java   | 273 ++++++++++++++++++++
 .../tika/pipes/core/PerClientServerManager.java    |   6 +
 .../org/apache/tika/pipes/core/PipesClient.java    |  12 +
 .../org/apache/tika/pipes/core/ServerManager.java  |  14 ++
 .../tika/pipes/core/PipesClientInterruptTest.java  | 280 +++++++++++++++++++++
 9 files changed, 617 insertions(+), 12 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 848567bb09..26dadf72e3 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -130,6 +130,15 @@ Release 4.0.0 - ???
      after delivering its reply, instead of leaving the client waiting
      for a terminal signal that never came (TIKA-4804).
 
+   * tika-grpc now routes fetchAndParse through the PipesParser client
+     pool instead of one shared single-threaded PipesClient: concurrent
+     calls no longer crash the worker, pipes.numClients and (for the
+     first time) pipes.useSharedServer take effect, up to numClients
+     forked worker JVMs instead of one, and pool saturation surfaces
+     in-band as CLIENT_UNAVAILABLE_WITHIN_MS. An interrupted call
+     closes its connection and recycles the per-client worker, so a
+     pooled client cannot go back to the queue dirty (TIKA-4815).
+
 
 Release 4.0.0-beta-1 - 6/29/2026
 
diff --git a/tika-grpc/README.md b/tika-grpc/README.md
index 2ebea44c00..00b865ab75 100644
--- a/tika-grpc/README.md
+++ b/tika-grpc/README.md
@@ -37,6 +37,14 @@ to avoid uploading hundreds of megabytes of native libraries 
and plugin bundles
   ```
   This produces `tika-grpc/target/tika-grpc-<version>.zip` but does **not** 
deploy it to Nexus.
 
+## Concurrency
+
+`fetchAndParse` runs on a pool of forked worker JVMs sized by 
`pipes.numClients`
+(when unset, derived from host cores, at most 4). A call that cannot get a
+worker within `pipes.maxWaitForClientMillis` (default 60s) returns the in-band
+status `CLIENT_UNAVAILABLE_WITHIN_MS`: the server is at capacity, not failing.
+With `pipes.useSharedServer: true` the workers share one JVM instead.
+
 ## Quick Start - Development Mode
 
 The fastest way to run tika-grpc in development mode with plugin hot-reloading:
diff --git 
a/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java 
b/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java
index 8f63ee4767..367d6333b4 100644
--- a/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java
+++ b/tika-grpc/src/main/java/org/apache/tika/pipes/grpc/TikaGrpcServerImpl.java
@@ -68,8 +68,9 @@ import org.apache.tika.pipes.api.fetcher.FetchKey;
 import org.apache.tika.pipes.api.fetcher.Fetcher;
 import org.apache.tika.pipes.api.fetcher.FetcherFactory;
 import org.apache.tika.pipes.api.pipesiterator.PipesIteratorFactory;
-import org.apache.tika.pipes.core.PipesClient;
 import org.apache.tika.pipes.core.PipesConfig;
+import org.apache.tika.pipes.core.PipesException;
+import org.apache.tika.pipes.core.PipesParser;
 import org.apache.tika.pipes.core.config.ConfigStore;
 import org.apache.tika.pipes.core.config.ConfigStoreFactory;
 import org.apache.tika.pipes.core.fetcher.FetcherManager;
@@ -88,7 +89,7 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase {
 
     PipesConfig pipesConfig;
     TikaGrpcConfig tikaGrpcConfig;
-    PipesClient pipesClient;
+    PipesParser pipesParser;
     FetcherManager fetcherManager;
     ConfigStore configStore;
     Path tikaConfigPath;
@@ -120,7 +121,8 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase {
         // modifications) are off unless explicitly enabled in the "grpc" 
section.
         tikaGrpcConfig = TikaGrpcConfig.load(tikaJsonConfig);
 
-        pipesClient = new PipesClient(pipesConfig, configPath);
+        // PipesClient is single-threaded; the pool admits pipes.numClients at 
a time.
+        pipesParser = PipesParser.load(tikaJsonConfig, pipesConfig, 
configPath);
         
         try {
             if (pluginRootsOverride != null && 
!pluginRootsOverride.trim().isEmpty()) {
@@ -304,7 +306,7 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase {
                 contextNode.fields().forEachRemaining(entry ->
                         parseContext.setJsonConfig(entry.getKey(), 
entry.getValue().toString()));
             }
-            PipesResult pipesResult = pipesClient.process(new 
FetchEmitTuple(request.getFetchKey(), new 
FetchKey(fetcher.getExtensionConfig().id(), request.getFetchKey()),
+            PipesResult pipesResult = pipesParser.parse(new 
FetchEmitTuple(request.getFetchKey(), new 
FetchKey(fetcher.getExtensionConfig().id(), request.getFetchKey()),
                     new EmitKey(), tikaMetadata, parseContext, 
FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP));
             FetchAndParseReply.Builder fetchReplyBuilder =
                     FetchAndParseReply.newBuilder()
@@ -324,7 +326,7 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase {
                 }
             }
             responseObserver.onNext(fetchReplyBuilder.build());
-        } catch (IOException e) {
+        } catch (IOException | PipesException e) {
             throw new RuntimeException(e);
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
@@ -630,17 +632,17 @@ class TikaGrpcServerImpl extends TikaGrpc.TikaImplBase {
     }
 
     /**
-     * Close the pipe client, to be called after TikaGrpcServer has shut down.
+     * Close the pipes parser, to be called after TikaGrpcServer has shut down.
      */
     void postShutdown() {
-        if (pipesClient != null) {
-            LOG.info("Shutting down the pipes client");
+        if (pipesParser != null) {
+            LOG.info("Shutting down the pipes parser");
             try {
-                pipesClient.close();
+                pipesParser.close();
             } catch (IOException e) {
-                LOG.error("Error closing the pipes client", e);
+                LOG.error("Error closing the pipes parser", e);
             } finally {
-                pipesClient = null;
+                pipesParser = null;
             }
         }
     }
diff --git a/tika-grpc/src/main/proto/tika.proto 
b/tika-grpc/src/main/proto/tika.proto
index 7d365f95d1..64d3f1e67b 100644
--- a/tika-grpc/src/main/proto/tika.proto
+++ b/tika-grpc/src/main/proto/tika.proto
@@ -113,7 +113,8 @@ message FetchAndParseReply {
   string fetch_key = 1;
   // Metadata fields from the parse output.
   map<string, string> fields = 2;
-  // The status from the message. See javadoc for 
org.apache.tika.pipes.PipesResult.STATUS for the list of status.
+  // The status from the message. See javadoc for
+  // org.apache.tika.pipes.api.PipesResult.RESULT_STATUS for the list of 
statuses.
   string status = 3;
   // If there was an error, this will contain the error message.
   string error_message = 4;
diff --git 
a/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcConcurrencyTest.java
 
b/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcConcurrencyTest.java
new file mode 100644
index 0000000000..d8064ae833
--- /dev/null
+++ 
b/tika-grpc/src/test/java/org/apache/tika/pipes/grpc/TikaGrpcConcurrencyTest.java
@@ -0,0 +1,273 @@
+/*
+ * 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.
+ */
+package org.apache.tika.pipes.grpc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import com.asarkar.grpc.test.GrpcCleanupExtension;
+import com.asarkar.grpc.test.Resources;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import io.grpc.ManagedChannel;
+import io.grpc.Server;
+import io.grpc.inprocess.InProcessChannelBuilder;
+import io.grpc.inprocess.InProcessServerBuilder;
+import org.apache.commons.io.FileUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.apache.tika.FetchAndParseReply;
+import org.apache.tika.FetchAndParseRequest;
+import org.apache.tika.TikaGrpc;
+import org.apache.tika.pipes.api.PipesResult;
+import org.apache.tika.pipes.fetcher.fs.FileSystemFetcher;
+import org.apache.tika.serialization.config.JsonConfigHelper;
+
+/**
+ * Concurrent fetchAndParse against a server built WITHOUT directExecutor(),
+ * like the production server: each call runs on its own handler thread, so
+ * these tests exercise the pipes layer under real handler concurrency.
+ */
+@ExtendWith(GrpcCleanupExtension.class)
+public class TikaGrpcConcurrencyTest {
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    // The fetcher must come from the config file: one saved at runtime through
+    // saveFetcher is not visible to the forked worker.
+    private static final String FETCHER_ID = "nick1:is:cool:super/" + 
FileSystemFetcher.class;
+
+    /**
+     * All concurrent calls must parse, and each reply must carry its own
+     * document. The barrier guarantees the calls overlap; the per-request
+     * marker catches replies wired to the wrong request even when every
+     * status says success.
+     */
+    @Test
+    public void concurrentCallsAllParseTheirOwnDocument(Resources resources) 
throws Exception {
+        runConcurrentBurst(resources, writeConfig(null, null, null));
+    }
+
+    /**
+     * The same burst with pipes.useSharedServer=true. This change makes shared
+     * mode reachable from tika-grpc for the first time (the old single-client
+     * constructor always forced per-client mode), so prove the wiring end to
+     * end: one shared worker JVM, two connections, four calls.
+     */
+    @Test
+    public void sharedServerModeParsesConcurrently(Resources resources) throws 
Exception {
+        runConcurrentBurst(resources, writeConfig(null, null, Boolean.TRUE));
+    }
+
+    private void runConcurrentBurst(Resources resources, Path config) throws 
Exception {
+        int concurrency = 4;
+        TikaGrpcServerImpl service = new 
TikaGrpcServerImpl(config.toAbsolutePath().toString());
+        List<File> testFiles = new ArrayList<>();
+        ExecutorService pool = Executors.newFixedThreadPool(concurrency);
+        try {
+            TikaGrpc.TikaBlockingStub stub = startServer(resources, service);
+            warmUp(stub, testFiles);
+
+            CyclicBarrier barrier = new CyclicBarrier(concurrency);
+            List<Callable<FetchAndParseReply>> calls = new ArrayList<>();
+            List<String> fetchKeys = new ArrayList<>();
+            List<String> markers = new ArrayList<>();
+            for (int i = 0; i < concurrency; i++) {
+                String marker = "tika4815-marker-" + i + "-" + 
UUID.randomUUID();
+                String fetchKey = "tika4815-doc-" + i + "-" + 
UUID.randomUUID() + ".html";
+                writeDoc(testFiles, fetchKey, marker);
+                fetchKeys.add(fetchKey);
+                markers.add(marker);
+                calls.add(() -> {
+                    barrier.await(30, TimeUnit.SECONDS);
+                    return stub.fetchAndParse(FetchAndParseRequest.newBuilder()
+                            .setFetcherId(FETCHER_ID)
+                            .setFetchKey(fetchKey)
+                            .build());
+                });
+            }
+            List<Future<FetchAndParseReply>> futures =
+                    pool.invokeAll(calls, 120, TimeUnit.SECONDS);
+            for (int i = 0; i < concurrency; i++) {
+                Future<FetchAndParseReply> future = futures.get(i);
+                assertFalse(future.isCancelled(),
+                        "call " + i + " did not finish within the time 
budget");
+                FetchAndParseReply reply = future.get();
+                assertEquals(fetchKeys.get(i), reply.getFetchKey());
+                assertEquals(PipesResult.RESULT_STATUS.PARSE_SUCCESS.name(), 
reply.getStatus(),
+                        "call " + i + " must parse; error: " + 
reply.getErrorMessage());
+                String marker = markers.get(i);
+                assertTrue(reply.getFieldsMap().values().stream()
+                                .anyMatch(v -> v.contains(marker)),
+                        "call " + i + " must carry its own document, not 
another call's");
+            }
+        } finally {
+            pool.shutdownNow();
+            service.postShutdown();
+            cleanUp(config, testFiles);
+        }
+    }
+
+    /**
+     * With one client and a zero wait, two overlapping calls must split into
+     * one parse and one in-band CLIENT_UNAVAILABLE_WITHIN_MS, the same way
+     * every other worker outcome already reaches the caller.
+     * <p>
+     * Deliberately not warmed up: the winning call holds the only client for
+     * the whole worker fork, seconds against the loser's zero-wait admission
+     * check. A warm worker would shrink that window to one small parse.
+     */
+    @Test
+    public void saturationSurfacesInBand(Resources resources) throws Exception 
{
+        Path config = writeConfig(1, 0L, null);
+        TikaGrpcServerImpl service = new 
TikaGrpcServerImpl(config.toAbsolutePath().toString());
+        List<File> testFiles = new ArrayList<>();
+        ExecutorService pool = Executors.newFixedThreadPool(2);
+        try {
+            TikaGrpc.TikaBlockingStub stub = startServer(resources, service);
+
+            CyclicBarrier barrier = new CyclicBarrier(2);
+            List<Callable<FetchAndParseReply>> calls = new ArrayList<>();
+            for (int i = 0; i < 2; i++) {
+                String fetchKey = "tika4815-sat-" + i + "-" + 
UUID.randomUUID() + ".html";
+                writeDoc(testFiles, fetchKey, "saturation " + i);
+                calls.add(() -> {
+                    barrier.await(30, TimeUnit.SECONDS);
+                    return stub.fetchAndParse(FetchAndParseRequest.newBuilder()
+                            .setFetcherId(FETCHER_ID)
+                            .setFetchKey(fetchKey)
+                            .build());
+                });
+            }
+            List<String> statuses = new ArrayList<>();
+            List<Future<FetchAndParseReply>> futures =
+                    pool.invokeAll(calls, 120, TimeUnit.SECONDS);
+            for (int i = 0; i < futures.size(); i++) {
+                Future<FetchAndParseReply> future = futures.get(i);
+                assertFalse(future.isCancelled(),
+                        "call " + i + " did not finish within the time 
budget");
+                statuses.add(future.get().getStatus());
+            }
+            Collections.sort(statuses);
+            assertEquals(List.of(
+                            
PipesResult.RESULT_STATUS.CLIENT_UNAVAILABLE_WITHIN_MS.name(),
+                            PipesResult.RESULT_STATUS.PARSE_SUCCESS.name()),
+                    statuses);
+        } finally {
+            pool.shutdownNow();
+            service.postShutdown();
+            cleanUp(config, testFiles);
+        }
+    }
+
+    private static TikaGrpc.TikaBlockingStub startServer(Resources resources,
+            TikaGrpcServerImpl service) throws Exception {
+        String serverName = InProcessServerBuilder.generateName();
+        // NOTE: no directExecutor() anywhere -- the production server
+        // (Grpc.newServerBuilderForPort) also dispatches on a thread pool.
+        Server server = InProcessServerBuilder.forName(serverName)
+                .addService(service)
+                .build()
+                .start();
+        resources.register(server, Duration.ofSeconds(30));
+        ManagedChannel channel = 
InProcessChannelBuilder.forName(serverName).build();
+        resources.register(channel, Duration.ofSeconds(30));
+        return TikaGrpc.newBlockingStub(channel);
+    }
+
+    /**
+     * One sequential call first, so the worker is already up and the burst
+     * cannot be blamed on cold start.
+     */
+    private static void warmUp(TikaGrpc.TikaBlockingStub stub, List<File> 
testFiles)
+            throws Exception {
+        String fetchKey = "tika4815-warmup-" + UUID.randomUUID() + ".html";
+        writeDoc(testFiles, fetchKey, "warmup");
+        FetchAndParseReply reply = 
stub.fetchAndParse(FetchAndParseRequest.newBuilder()
+                .setFetcherId(FETCHER_ID)
+                .setFetchKey(fetchKey)
+                .build());
+        assertEquals(PipesResult.RESULT_STATUS.PARSE_SUCCESS.name(), 
reply.getStatus(),
+                "the warmup fixture must parse, or this test proves nothing");
+    }
+
+    private static void writeDoc(List<File> testFiles, String fetchKey, String 
marker)
+            throws Exception {
+        File doc = new File("target", fetchKey);
+        synchronized (testFiles) {
+            testFiles.add(doc);
+        }
+        FileUtils.writeStringToFile(doc,
+                "<html><head><title>" + marker + "</title></head><body>" + 
marker
+                        + "</body></html>", StandardCharsets.UTF_8);
+    }
+
+    private static Path writeConfig(Integer numClients, Long 
maxWaitForClientMillis,
+            Boolean useSharedServer) throws Exception {
+        Path config = Paths.get("target", "tika4815-config-" + 
UUID.randomUUID() + ".json");
+        Map<String, Object> replacements = new HashMap<>();
+        replacements.put("JAVA_PATH", 
Paths.get(System.getProperty("java.home"), "bin", "java"));
+        replacements.put("FETCHER_BASE_PATH", 
Paths.get("target").toAbsolutePath());
+        replacements.put("PLUGIN_ROOTS", 
Paths.get("target").toAbsolutePath().resolve("plugins"));
+        
JsonConfigHelper.writeConfigFromResource("/tika-pipes-test-config.json",
+                TikaGrpcConcurrencyTest.class, replacements, config);
+        if (numClients != null || maxWaitForClientMillis != null || 
useSharedServer != null) {
+            ObjectNode root = (ObjectNode) 
OBJECT_MAPPER.readTree(config.toFile());
+            ObjectNode pipes = (ObjectNode) root.get("pipes");
+            if (numClients != null) {
+                pipes.put("numClients", numClients);
+            }
+            if (maxWaitForClientMillis != null) {
+                pipes.put("maxWaitForClientMillis", maxWaitForClientMillis);
+            }
+            if (useSharedServer != null) {
+                pipes.put("useSharedServer", useSharedServer);
+            }
+            Files.writeString(config, 
OBJECT_MAPPER.writerWithDefaultPrettyPrinter()
+                    .writeValueAsString(root), StandardCharsets.UTF_8);
+        }
+        return config;
+    }
+
+    private static void cleanUp(Path config, List<File> testFiles) throws 
Exception {
+        Files.deleteIfExists(config);
+        for (File f : testFiles) {
+            FileUtils.deleteQuietly(f);
+        }
+    }
+}
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
index c4077a18b2..024afd292e 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
@@ -288,6 +288,12 @@ public class PerClientServerManager implements 
ServerManager {
         pendingRestart = true;
     }
 
+    @Override
+    public void connectionAbandoned() {
+        LOG.info("clientId={}: connection abandoned mid-request, recycling the 
worker", clientId);
+        pendingRestart = true;
+    }
+
     @Override
     public int handleCrashAndGetExitCode() {
         pendingRestart = true;
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java
index 7fb00cb9f3..d0db707baf 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java
@@ -208,6 +208,13 @@ public class PipesClient implements Closeable {
         PipesResult result = null;
         try {
             maybeInit();
+        } catch (InterruptedException e) {
+            // Same invariant as the in-flight path below: an abandoned 
connection,
+            // here possibly half-established, must not be re-queued, and an
+            // abandoned per-client worker never dials back.
+            serverManager.connectionAbandoned();
+            closeConnection();
+            throw e;
         } catch (ServerInitializationException e) {
             LOG.error("server initialization failed: {} ", t.getId(), e);
             closeConnection();
@@ -227,6 +234,11 @@ public class PipesClient implements Closeable {
             // Update server manager's file counter for 
maxFilesProcessedPerProcess tracking
             
serverManager.incrementFilesProcessed(pipesConfig.getMaxFilesProcessedPerProcess());
         } catch (InterruptedException | SecurityException e) {
+            // A pooled client is re-queued right after this throw; the next 
borrower
+            // must not inherit a connection with a request still in flight, 
nor a
+            // per-client worker that will never dial back for a fresh connect.
+            serverManager.connectionAbandoned();
+            closeConnection();
             throw e;
         } catch (Exception e) {
             LOG.error("exception waiting for server to complete task: {} ", 
t.getId(), e);
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ServerManager.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ServerManager.java
index e84bfbd754..3a663193e6 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ServerManager.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ServerManager.java
@@ -110,6 +110,20 @@ public interface ServerManager extends Closeable {
         // Default no-op for backward compatibility
     }
 
+    /**
+     * Signals that the client walked away from its connection, whether
+     * mid-handshake or with a request still in flight.
+     * <p>
+     * A per-client worker dials the parent once and never dials back, so an
+     * abandoned worker must be recycled or the next {@link #connect(int)} 
waits
+     * out the full accept timeout against a process that will never call. A
+     * shared server outlives its clients and needs nothing here; its
+     * connection handlers already detect the dead socket themselves.
+     */
+    default void connectionAbandoned() {
+        // Default no-op: only per-client mode must recycle the worker
+    }
+
     /**
      * Increments the count of files processed and marks for restart if limit 
reached.
      * <p>
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/PipesClientInterruptTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/PipesClientInterruptTest.java
new file mode 100644
index 0000000000..f6b478fd73
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/PipesClientInterruptTest.java
@@ -0,0 +1,280 @@
+/*
+ * 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.
+ */
+package org.apache.tika.pipes.core;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.pipes.api.FetchEmitTuple;
+import org.apache.tika.pipes.api.emitter.EmitKey;
+import org.apache.tika.pipes.api.fetcher.FetchKey;
+import org.apache.tika.pipes.core.protocol.PipesMessage;
+import org.apache.tika.pipes.core.protocol.PipesMessageType;
+
+/**
+ * A scripted stand-in for the forked server proves what happens to the
+ * connection when the thread inside {@link PipesClient#process} is
+ * interrupted. The client rethrows InterruptedException; a pooled client
+ * then goes back to the queue, so the connection must not stay open with a
+ * request in flight -- the next borrower's ping would hang on it until
+ * the socket timeout.
+ */
+public class PipesClientInterruptTest {
+
+    /**
+     * Interrupting an in-flight process() must close the connection: the
+     * scripted server sees SHUT_DOWN or EOF instead of a socket that stays
+     * open with an abandoned request on it.
+     */
+    @Test
+    @Timeout(30)
+    public void interruptClosesTheConnection() throws Exception {
+        try (ServerSocket serverSocket = new ServerSocket(0)) {
+            CountDownLatch heartbeatStarted = new CountDownLatch(1);
+            CountDownLatch connectionClosed = new CountDownLatch(1);
+            Thread sentinel = new Thread(() ->
+                    runScriptedServer(serverSocket, heartbeatStarted, 
connectionClosed));
+            sentinel.setDaemon(true);
+            sentinel.start();
+
+            PipesConfig pipesConfig = new PipesConfig();
+            SentinelServerManager manager = new 
SentinelServerManager(serverSocket.getLocalPort());
+            PipesClient client = new PipesClient(pipesConfig, manager);
+
+            AtomicReference<Throwable> fromProcess = new AtomicReference<>();
+            CountDownLatch processReturned = new CountDownLatch(1);
+            Thread worker = new Thread(() -> {
+                try {
+                    client.process(new FetchEmitTuple("interrupt-test",
+                            new FetchKey("fetcher", "key"), new EmitKey(), new 
Metadata(),
+                            new ParseContext(), 
FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP));
+                } catch (Throwable t) {
+                    fromProcess.set(t);
+                } finally {
+                    processReturned.countDown();
+                }
+            });
+            worker.start();
+
+            assertTrue(heartbeatStarted.await(15, TimeUnit.SECONDS),
+                    "the scripted server never got the request; the test 
proves nothing");
+            worker.interrupt();
+
+            assertTrue(processReturned.await(15, TimeUnit.SECONDS),
+                    "process() must return after the interrupt");
+            assertTrue(fromProcess.get() instanceof InterruptedException,
+                    "process() must rethrow the interrupt, got: " + 
fromProcess.get());
+            assertTrue(connectionClosed.await(5, TimeUnit.SECONDS),
+                    "the interrupted client left its connection open with a 
request in flight");
+            assertTrue(manager.abandoned,
+                    "the manager was not told; a per-client worker never dials 
back, so the "
+                            + "next connect() would wait out the accept 
timeout for nothing");
+            client.close();
+        }
+    }
+
+    /**
+     * Interrupting during startup retry must leave the same state as
+     * interrupting mid-parse: connection closed, manager told. The scripted
+     * server breaks the handshake (a valid frame of the wrong type instead of
+     * READY), which lands the client in the retry backoff where the interrupt
+     * is delivered.
+     */
+    @Test
+    @Timeout(30)
+    public void interruptDuringStartupBackoffAbandonsTheConnection() throws 
Exception {
+        try (ServerSocket serverSocket = new ServerSocket(0)) {
+            CountDownLatch badHandshakeSent = new CountDownLatch(1);
+            CountDownLatch connectionClosed = new CountDownLatch(1);
+            Thread sentinel = new Thread(() ->
+                    runBadHandshakeServer(serverSocket, badHandshakeSent, 
connectionClosed));
+            sentinel.setDaemon(true);
+            sentinel.start();
+
+            PipesConfig pipesConfig = new PipesConfig();
+            SentinelServerManager manager = new 
SentinelServerManager(serverSocket.getLocalPort());
+            PipesClient client = new PipesClient(pipesConfig, manager);
+
+            AtomicReference<Throwable> fromProcess = new AtomicReference<>();
+            CountDownLatch processReturned = new CountDownLatch(1);
+            Thread worker = new Thread(() -> {
+                try {
+                    client.process(new FetchEmitTuple("interrupt-startup-test",
+                            new FetchKey("fetcher", "key"), new EmitKey(), new 
Metadata(),
+                            new ParseContext(), 
FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP));
+                } catch (Throwable t) {
+                    fromProcess.set(t);
+                } finally {
+                    processReturned.countDown();
+                }
+            });
+            worker.start();
+
+            assertTrue(badHandshakeSent.await(15, TimeUnit.SECONDS),
+                    "the scripted server never got a connection; the test 
proves nothing");
+            worker.interrupt();
+
+            assertTrue(processReturned.await(15, TimeUnit.SECONDS),
+                    "process() must return after the interrupt");
+            assertTrue(fromProcess.get() instanceof InterruptedException,
+                    "process() must rethrow the interrupt, got: " + 
fromProcess.get());
+            assertTrue(connectionClosed.await(5, TimeUnit.SECONDS),
+                    "the interrupted client left its half-established 
connection open");
+            assertTrue(manager.abandoned,
+                    "the manager was not told; an abandoned per-client worker 
never "
+                            + "dials back, mid-handshake or not");
+            client.close();
+        }
+    }
+
+    /**
+     * Accepts one connection and answers the handshake with a valid frame of
+     * the wrong type, sending the client into its reconnect backoff. Releases
+     * connectionClosed when the client sends SHUT_DOWN or the socket reaches
+     * EOF.
+     */
+    private static void runBadHandshakeServer(ServerSocket serverSocket,
+            CountDownLatch badHandshakeSent, CountDownLatch connectionClosed) {
+        try (Socket socket = serverSocket.accept();
+                DataInputStream in = new 
DataInputStream(socket.getInputStream());
+                DataOutputStream out = new 
DataOutputStream(socket.getOutputStream())) {
+            PipesMessage.ack().write(out);
+            badHandshakeSent.countDown();
+            while (true) {
+                PipesMessage message = PipesMessage.read(in);
+                if (message.type() == PipesMessageType.SHUT_DOWN) {
+                    break;
+                }
+            }
+            connectionClosed.countDown();
+        } catch (IOException e) {
+            // EOF or reset: the connection is gone either way
+            connectionClosed.countDown();
+        }
+    }
+
+    /**
+     * Accepts one connection and speaks just enough protocol: READY, consume
+     * the NEW_REQUEST, then WORKING heartbeats -- each one wakes the client's
+     * read loop so the interrupt check runs. Releases connectionClosed when
+     * the client sends SHUT_DOWN or the socket reaches EOF.
+     */
+    private static void runScriptedServer(ServerSocket serverSocket,
+            CountDownLatch heartbeatStarted, CountDownLatch connectionClosed) {
+        try (Socket socket = serverSocket.accept();
+                DataInputStream in = new 
DataInputStream(socket.getInputStream());
+                DataOutputStream out = new 
DataOutputStream(socket.getOutputStream())) {
+            PipesMessage.ready().write(out);
+            PipesMessage.read(in); // NEW_REQUEST
+
+            Thread heartbeat = new Thread(() -> {
+                try {
+                    while (true) {
+                        PipesMessage.working().write(out);
+                        heartbeatStarted.countDown();
+                        Thread.sleep(100);
+                    }
+                } catch (IOException | InterruptedException e) {
+                    // socket closed under us, or test over -- either way, done
+                }
+            });
+            heartbeat.setDaemon(true);
+            heartbeat.start();
+
+            while (true) {
+                PipesMessage message = PipesMessage.read(in);
+                if (message.type() == PipesMessageType.SHUT_DOWN) {
+                    break;
+                }
+            }
+            connectionClosed.countDown();
+        } catch (EOFException e) {
+            // close without SHUT_DOWN still counts: the connection is gone
+            connectionClosed.countDown();
+        } catch (IOException e) {
+            connectionClosed.countDown();
+        }
+    }
+
+    /**
+     * Points the client at the scripted server; no forked process anywhere.
+     */
+    private static final class SentinelServerManager implements ServerManager {
+        private final int port;
+        private volatile boolean abandoned;
+
+        private SentinelServerManager(int port) {
+            this.port = port;
+        }
+
+        @Override
+        public void connectionAbandoned() {
+            abandoned = true;
+        }
+
+        @Override
+        public int getPort() {
+            return port;
+        }
+
+        @Override
+        public void ensureRunning() {
+            // the scripted server is already listening
+        }
+
+        @Override
+        public Socket connect(int socketTimeoutMs) throws IOException {
+            Socket socket = new Socket("localhost", port);
+            socket.setSoTimeout(socketTimeoutMs);
+            return socket;
+        }
+
+        @Override
+        public void shutdown() {
+            // nothing to shut down
+        }
+
+        @Override
+        public boolean isRunning() {
+            return true;
+        }
+
+        @Override
+        public java.nio.file.Path getTempDirectory() {
+            return null;
+        }
+
+        @Override
+        public void close() {
+            // nothing to close
+        }
+    }
+}

Reply via email to