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 ab22ec9c9a TIKA-4844: scope shared-server restart reports to the
process they de… (#3077)
ab22ec9c9a is described below
commit ab22ec9c9af8a224f3ede600421221d023dcc319
Author: Tim Allison <[email protected]>
AuthorDate: Wed Aug 26 17:52:16 2026 -0400
TIKA-4844: scope shared-server restart reports to the process they de…
(#3077)
---
CHANGES.txt | 18 ++++
.../org/apache/tika/pipes/core/PipesClient.java | 22 +++--
.../org/apache/tika/pipes/core/PipesParser.java | 9 ++
.../org/apache/tika/pipes/core/ServerManager.java | 23 +++++
.../tika/pipes/core/SharedServerManager.java | 71 ++++++++++++++--
.../pipes/core/SharedServerManagerClosedTest.java | 45 ++++++++++
.../tika/pipes/core/SharedServerModeTest.java | 98 ++++++++++++++++++++++
7 files changed, 275 insertions(+), 11 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 5ae3270114..7c0d7b9084 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,23 @@
Release 4.1.0 - unreleased
+ * Shared pipes server (useSharedServer: true, not the default): a client
whose
+ in-flight parse was killed by another client's restart could restart the
+ healthy replacement. ensureRunning holds its lock across the whole fork,
so
+ siblings cannot report a dead worker until after the replacement is up,
and
+ the pending-restart flag carried no process identity -- so a report about
+ the process that just died was applied to its successor, which was then
+ destroyed and re-forked. One worker death produced two restarts and a
second
+ round of destroyed in-flight work; under sustained concurrent load it
+ sustained itself at one spurious restart per round, appearing as periodic
+ unexplained worker churn and intermittent parse failures that succeed on
+ retry. Each fork now carries a generation that clients capture when they
+ connect and hand back with every report, and reports about a superseded
+ process are dropped. Also fixed in shared mode: ensureRunning could fork a
+ replacement after shutdown() that nothing owned and nothing would ever
+ destroy, and an interrupt during process teardown left the process handle
+ pointing at a killed process and leaked the temp directory. Affects 4.0.0
+ and earlier (TIKA-4844).
+
* tika-pipes: the cache memory budget (how much rewindable content a forked
worker keeps in memory before spilling to disk; new since 4.0.0, which had
no budget at all) defaults to a quarter of the fork's heap, so raising
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 7b2b34a974..c4743e1a65 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
@@ -82,6 +82,13 @@ public class PipesClient implements Closeable {
// calling close(). The lock lets one thread atomically claim and null the
tuple.
private final Object connectionLock = new Object();
private volatile ConnectionTuple connectionTuple;
+ /**
+ * The server generation this client's connection belongs to, captured in
{@link #reconnect()}.
+ * Starts at MAX_VALUE so a report made before we have ever connected is
never mistaken for a
+ * stale one: dropping a legitimate report can wedge the pool, while an
extra one only costs a
+ * restart, so the un-connected case must fail toward reporting.
+ */
+ private volatile long connectionGeneration = Long.MAX_VALUE;
private int filesProcessed = 0;
/**
@@ -325,6 +332,9 @@ public class PipesClient implements Closeable {
// Get port after ensureRunning - this is the port we'll connect to
int port = serverManager.getPort();
+ // Captured with the port: every report we make below is about THIS
process, and must be
+ // dropped if a sibling has already replaced it.
+ connectionGeneration = serverManager.getGeneration();
LOG.debug("pipesClientId={}: connecting to server", pipesClientId);
// Connect to server. Use the generous startup timeout as the read
SO_TIMEOUT so the
@@ -404,7 +414,7 @@ public class PipesClient implements Closeable {
LOG.warn("clientId={}: client-side backstop timeout: id={}
elapsed={}ms limit={}ms " +
"-- server should have self-terminated well
before this", pipesClientId,
t.getId(), totalElapsed, clientBackstopMillis);
- serverManager.markServerForRestart();
+ serverManager.markServerForRestart(connectionGeneration);
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(), TIMEOUT,
intermediateResult.get());
}
@@ -420,19 +430,19 @@ public class PipesClient implements Closeable {
switch (msg.type()) {
case OOM:
String oomMsg = JsonPipesIpc.fromBytes(msg.payload(),
String.class);
- serverManager.markServerForRestart();
+
serverManager.markServerForRestart(connectionGeneration);
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(),
PipesResult.RESULT_STATUS.OOM,
intermediateResult.get(), oomMsg);
case TIMEOUT:
String timeoutMsg =
JsonPipesIpc.fromBytes(msg.payload(), String.class);
- serverManager.markServerForRestart();
+
serverManager.markServerForRestart(connectionGeneration);
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(),
TIMEOUT,
intermediateResult.get(), timeoutMsg);
case UNSPECIFIED_CRASH:
String crashMsg =
JsonPipesIpc.fromBytes(msg.payload(), String.class);
- serverManager.markServerForRestart();
+
serverManager.markServerForRestart(connectionGeneration);
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(),
UNSPECIFIED_CRASH,
intermediateResult.get(), crashMsg);
@@ -466,7 +476,7 @@ public class PipesClient implements Closeable {
} catch (SocketTimeoutException e) {
LOG.warn("clientId={}: Socket timeout exception while waiting
for server", pipesClientId, e);
// Mark for restart - server is stuck on current request and
needs to be restarted
- serverManager.markServerForRestart();
+ serverManager.markServerForRestart(connectionGeneration);
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(), TIMEOUT,
intermediateResult.get(),
ExceptionUtils.getStackTrace(e));
@@ -482,7 +492,7 @@ public class PipesClient implements Closeable {
} catch (Exception e) {
LOG.warn("clientId={} - crash while waiting for server",
pipesClientId, e);
// Handle crash and determine status based on exit code
- int exitCode = serverManager.handleCrashAndGetExitCode();
+ int exitCode =
serverManager.handleCrashAndGetExitCode(connectionGeneration);
PipesResult.RESULT_STATUS status = UNSPECIFIED_CRASH;
if (exitCode == PipesMessageType.OOM.getExitCode().orElse(-1))
{
status = PipesResult.RESULT_STATUS.OOM;
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesParser.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesParser.java
index dd68dca002..a03e24efe5 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesParser.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesParser.java
@@ -173,6 +173,15 @@ public class PipesParser implements Closeable {
*
* @return true if using shared server mode
*/
+ /**
+ * Total forks performed across this parser's server managers. One more
than the number of
+ * restarts, since the first start of each manager is not a restart.
Exposed for tests: it is
+ * the only observable that distinguishes a justified restart from a
spurious one.
+ */
+ public long getGeneration() {
+ return
serverManagers.stream().mapToLong(ServerManager::getGeneration).sum();
+ }
+
public boolean isSharedMode() {
return isSharedMode;
}
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 3a663193e6..b40d8c4757 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
@@ -163,4 +163,27 @@ public interface ServerManager extends Closeable {
markServerForRestart();
return -1;
}
+
+ /**
+ * The generation of the currently running process: a counter incremented
every time this
+ * manager forks a replacement. A client captures it when it connects and
hands it back with
+ * every report, so a report about a process that has already been
replaced can be recognised
+ * and dropped rather than being applied to its healthy successor.
+ */
+ default long getGeneration() {
+ return 0;
+ }
+
+ /**
+ * As {@link #markServerForRestart()}, but only if {@code generation} is
still current.
+ * Reports about a superseded process are dropped.
+ */
+ default void markServerForRestart(long generation) {
+ markServerForRestart();
+ }
+
+ /** As {@link #handleCrashAndGetExitCode()}, but only if {@code
generation} is still current. */
+ default int handleCrashAndGetExitCode(long generation) {
+ return handleCrashAndGetExitCode();
+ }
}
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java
index ea6db80039..93b4f03828 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java
@@ -76,7 +76,9 @@ public class SharedServerManager implements ServerManager {
private final Object lock = new Object();
private final AtomicLong filesProcessed = new AtomicLong(0);
private volatile boolean restarting = false;
- private volatile boolean pendingRestart = false; // Set when a client
reports fatal error or max files reached
+ private volatile boolean pendingRestart = false;
+ private volatile long generation;
+ private volatile boolean closed; // Set when a client reports fatal error
or max files reached
private volatile byte[] currentToken;
private Process process;
private Path tmpDir;
@@ -141,9 +143,13 @@ public class SharedServerManager implements ServerManager {
if (isProcessAlive() && !pendingRestart) {
return;
}
+ if (closed) {
+ throw new IllegalStateException("shared server manager is
closed");
+ }
restarting = true;
try {
startServer();
+ generation++; // supersedes every report still in flight
against the old process
pendingRestart = false; // Clear the flag after successful
restart
filesProcessed.set(0); // Reset file counter on restart
} finally {
@@ -171,6 +177,51 @@ public class SharedServerManager implements ServerManager {
}
}
+ @Override
+ public long getGeneration() {
+ synchronized (lock) {
+ return generation;
+ }
+ }
+
+ @Override
+ public void markServerForRestart(long generation) {
+ synchronized (lock) {
+ if (isSuperseded(generation)) {
+ return;
+ }
+ LOG.debug("Server marked for restart - will restart on next
ensureRunning()");
+ pendingRestart = true;
+ }
+ }
+
+ @Override
+ public int handleCrashAndGetExitCode(long generation) {
+ synchronized (lock) {
+ if (isSuperseded(generation)) {
+ return -1;
+ }
+ pendingRestart = true;
+ }
+ return -1;
+ }
+
+ /**
+ * A restart kills the shared JVM out from under every sibling still
parsing on it, and
+ * {@code ensureRunning} holds {@code lock} for the whole fork, so those
siblings cannot
+ * report until after the replacement is up. Their reports describe the
process that was
+ * already destroyed; applying them would destroy the healthy replacement
too. Callers
+ * hold {@code lock}.
+ */
+ private boolean isSuperseded(long reportedGeneration) {
+ if (reportedGeneration >= generation) {
+ return false;
+ }
+ LOG.debug("dropping stale restart report for generation {}; current
generation is {}",
+ reportedGeneration, generation);
+ return true;
+ }
+
/**
* Increments the count of files processed and marks for restart if limit
reached.
*/
@@ -378,6 +429,11 @@ public class SharedServerManager implements ServerManager {
@Override
public void shutdown() throws InterruptedException {
synchronized (lock) {
+ // Latch here, not in shutdownUnsafe(): startServer() calls that
to reap the previous
+ // process on every restart, so latching there would brick the
manager on restart #1.
+ // A request thread racing us into ensureRunning must fail rather
than fork a
+ // replacement that nothing owns and nothing will ever destroy.
+ closed = true;
shutdownUnsafe();
}
}
@@ -398,11 +454,16 @@ public class SharedServerManager implements ServerManager
{
private void destroyProcessUnsafe() throws InterruptedException {
if (process != null) {
process.destroyForcibly();
- process.waitFor(WAIT_ON_DESTROY_MS, TimeUnit.MILLISECONDS);
- if (process.isAlive()) {
- LOG.error("Shared server process still alive after {}ms",
WAIT_ON_DESTROY_MS);
+ try {
+ process.waitFor(WAIT_ON_DESTROY_MS, TimeUnit.MILLISECONDS);
+ if (process.isAlive()) {
+ LOG.error("Shared server process still alive after {}ms",
WAIT_ON_DESTROY_MS);
+ }
+ } finally {
+ // An interrupt here must not leave the field pointing at a
SIGKILLed process:
+ // startServer() would then try to reap it again and tmpDir
would never be deleted.
+ process = null;
}
- process = null;
}
}
diff --git
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/SharedServerManagerClosedTest.java
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/SharedServerManagerClosedTest.java
new file mode 100644
index 0000000000..49cdb65492
--- /dev/null
+++
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/SharedServerManagerClosedTest.java
@@ -0,0 +1,45 @@
+/*
+ * 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.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * A request thread can race a teardown into {@code ensureRunning}. Without a
closed latch it
+ * forks a replacement that nothing owns and nothing will ever destroy; the
child only exits when
+ * the parent JVM does. PerClientServerManager has guarded this since 4.0.0;
shared mode did not.
+ */
+public class SharedServerManagerClosedTest {
+
+ @Test
+ public void testEnsureRunningAfterShutdownDoesNotFork() throws Exception {
+ SharedServerManager sm = new SharedServerManager(new PipesConfig(),
null, 2);
+ sm.shutdown();
+ assertThrows(IllegalStateException.class, sm::ensureRunning,
+ "ensureRunning must refuse to fork once the manager has been
shut down");
+ }
+
+ @Test
+ public void testShutdownIsIdempotent() throws Exception {
+ SharedServerManager sm = new SharedServerManager(new PipesConfig(),
null, 2);
+ sm.shutdown();
+ assertDoesNotThrow(sm::shutdown, "a second shutdown must be a no-op,
not a failure");
+ }
+}
diff --git
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/SharedServerModeTest.java
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/SharedServerModeTest.java
index 65e3806a93..0e5a2d2e23 100644
---
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/SharedServerModeTest.java
+++
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/SharedServerModeTest.java
@@ -24,13 +24,16 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
+import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.io.TempDir;
import org.apache.tika.config.loader.TikaJsonConfig;
@@ -619,6 +622,8 @@ public class SharedServerModeTest {
assertTrue(verifyResult.isSuccess(),
"After concurrent OOM, server should restart and process
new request. Got: " + verifyResult.status());
+
+ assertOneForkPerDeath(pipesParser, 1);
}
}
@@ -762,6 +767,99 @@ public class SharedServerModeTest {
"All 5 phase 2 requests should succeed after server
recovery. " +
"Phase 1 had: " + phase1OomCount + " OOMs, " +
phase1SuccessCount + " successes, " +
phase1CrashCount + " crashes");
+
+ assertOneForkPerDeath(pipesParser, 1);
+ }
+ }
+
+ /**
+ * The shared JVM died {@code deaths} times. Siblings whose in-flight
parses were killed by a
+ * death report a crash <em>result</em>, which is correct -- but none of
them is another death,
+ * so none may cause another fork. Generation counts forks directly, so it
cannot be satisfied
+ * by relabelling a spurious restart: one death, one replacement.
+ */
+ private void assertOneForkPerDeath(PipesParser pipesParser, int deaths) {
+ long forks = pipesParser.getGeneration();
+ assertEquals(deaths + 1, forks,
+ "expected the initial fork plus exactly " + deaths + "
replacement(s); a sibling's "
+ + "report about an already-replaced process must not
restart its healthy "
+ + "successor (forks=" + forks + ")");
+ }
+
+ /**
+ * The conservation law behind every restart-accounting bug in this class:
a fork must be
+ * caused by something. At most {@code ooms} worker deaths can occur (one
document can kill the
+ * shared JVM once), no file limit is reached in these short rounds, so
the manager may fork at
+ * most that many replacements on top of its initial start.
+ * <p>
+ * Unlike the fixed OOM fixtures, this does not depend on catching one
lucky interleaving --
+ * it holds under every schedule, so a spurious restart is caught whether
or not the timing
+ * that produced it happened to repeat. Seeds are fixed so a failure is
reproducible; the
+ * concurrency supplies the variation.
+ */
+ @Test
+ @Timeout(600)
+ public void testForksNeverExceedInjectedFaults(@TempDir Path tmp) throws
Exception {
+ for (int seed = 1; seed <= 3; seed++) {
+ runFaultRound(seed, Files.createDirectories(tmp.resolve("seed" +
seed)));
+ }
+ }
+
+ private void runFaultRound(int seed, Path tmp) throws Exception {
+ Random random = new Random(seed);
+ Path inputDir = setupInputDir(tmp);
+
+ int ooms = 1 + random.nextInt(2);
+ List<String> names = new ArrayList<>();
+ for (int i = 0; i < ooms; i++) {
+ String name = "oom" + i + ".xml";
+ Files.writeString(inputDir.resolve(name), MOCK_OOM,
StandardCharsets.UTF_8);
+ names.add(name);
+ }
+ for (int i = 0; i < 4; i++) {
+ String name = "slow" + i + ".xml";
+ Files.writeString(inputDir.resolve(name), MOCK_SLOW,
StandardCharsets.UTF_8);
+ names.add(name);
+ }
+ for (int i = 0; i < 6; i++) {
+ String name = "ok" + i + ".xml";
+ Files.writeString(inputDir.resolve(name), MOCK_OK,
StandardCharsets.UTF_8);
+ names.add(name);
+ }
+ Collections.shuffle(names, random);
+
+ Path tikaConfigPath = PluginsTestHelper.getFileSystemFetcherConfig(
+ "tika-config-shared-server.json", tmp, inputDir,
tmp.resolve("output"), false);
+ TikaJsonConfig tikaJsonConfig = TikaJsonConfig.load(tikaConfigPath);
+ PipesConfig pipesConfig = PipesConfig.load(tikaJsonConfig);
+
+ try (PipesParser pipesParser = PipesParser.load(tikaJsonConfig,
pipesConfig, tikaConfigPath)) {
+ ExecutorService executor = Executors.newFixedThreadPool(6);
+ try {
+ List<Future<PipesResult>> futures = new ArrayList<>();
+ for (String name : names) {
+ futures.add(executor.submit(() -> pipesParser.parse(new
FetchEmitTuple(
+ name,
+ new FetchKey(FETCHER_NAME, name),
+ new EmitKey(EMITTER_NAME, ""),
+ new Metadata(),
+ new ParseContext(),
+ FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP))));
+ }
+ for (Future<PipesResult> future : futures) {
+ future.get();
+ }
+ } finally {
+ executor.shutdown();
+ executor.awaitTermination(30, TimeUnit.SECONDS);
+ }
+
+ long forks = pipesParser.getGeneration();
+ assertTrue(forks - 1 <= ooms,
+ "seed=" + seed + ": the shared worker forked " + (forks -
1) + " replacement(s) "
+ + "but at most " + ooms + " death(s) could have
occurred; a report about "
+ + "an already-replaced process must not restart
its healthy successor");
+ assertTrue(forks >= 1, "seed=" + seed + ": the worker must have
started at least once");
}
}