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 5fd4c92e7f TIKA-4905: clamp the forked-worker CPU cap at its floor
instead of switching it off (#3195)
5fd4c92e7f is described below
commit 5fd4c92e7f3327182f5d70fd883fe9d8415b68ec
Author: Tim Allison <[email protected]>
AuthorDate: Fri Sep 18 13:32:57 2026 -0400
TIKA-4905: clamp the forked-worker CPU cap at its floor instead of
switching it off (#3195)
---
CHANGES.txt | 3 ++
.../tika/pipes/core/PerClientServerManager.java | 62 +++++++++++++---------
.../core/PerClientServerManagerSizingTest.java | 30 ++++++++---
3 files changed, 62 insertions(+), 33 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 651fc6c68f..babfb774b0 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,8 @@
Release 4.1.0 - unreleased
+ * The forked-worker CPU cap (-XX:ActiveProcessorCount) is now clamped at its
+ floor of 2 (TIKA-4905).
+
* Tesseract script models are accepted by their bare name ("language":
"Latin", "eng+Japanese_vert") (TIKA-4903).
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 a30d3e24a0..24f53d2822 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
@@ -58,12 +58,25 @@ public class PerClientServerManager implements
ServerManager {
* response deserialization, and heartbeat bookkeeping; if it's
CPU-starved
* small operations like socket flush show pathological tail latency. */
private static final int PARENT_RESERVED_CORES = 2;
- /** Don't auto-cap below this many CPUs per fork. At cap=1 the fork's only
- * CPU is fully consumed by parsing, so its socket-reader thread can't run
- * and the parent's writes block on receiver-side back-pressure -- worse
- * than no cap at all. This guard matters for small k8s pods where the
- * formula could otherwise produce slice=1. */
- private static final int MIN_AUTO_CAP_SLICE = 2;
+ /** Floor for the auto-cap. At cap=1 the fork's only CPU is fully consumed
by
+ * parsing, so its socket-reader thread can't run and the parent's writes
block
+ * on receiver-side back-pressure. The floor is a CLAMP, not a switch:
when the
+ * fair slice falls below it, the fork still gets this cap rather than
none.
+ * Skipping the cap hands every fork all the host's cores, and N forks
then each
+ * size their GC/JIT pools for the whole machine -- measured 2026-09-18
at 10
+ * forks on 16 cores as +18% wall against the clamped run (TIKA-4905). */
+ static final int MIN_AUTO_CAP_SLICE = 2;
+
+ /**
+ * The {@code -XX:ActiveProcessorCount} each fork gets when the user has
not set one: a
+ * fair share of the host after the parent's reservation, never below
+ * {@link #MIN_AUTO_CAP_SLICE}. Over-provisioned hosts (numClients above
the recommended
+ * max) land on the floor for every fork; the caller warns separately.
+ */
+ static int autoCapSlice(int hostCores, int numClients) {
+ int budget = Math.max(1, hostCores - PARENT_RESERVED_CORES);
+ return Math.max(MIN_AUTO_CAP_SLICE, budget / Math.max(1, numClients));
+ }
/** Share of host/container memory the forks may collectively claim; the
remainder is
* left for the parent JVM, the OS, and page cache for spooled input. */
@@ -236,11 +249,11 @@ public class PerClientServerManager implements
ServerManager {
if (userSetCap) {
capDecision = "user-set in forkedJvmArgs";
} else {
- int budget = Math.max(1, hostCores - PARENT_RESERVED_CORES);
- int slice = budget / numClients;
- capDecision = (slice >= MIN_AUTO_CAP_SLICE)
+ int fair = Math.max(1, hostCores - PARENT_RESERVED_CORES) /
Math.max(1, numClients);
+ int slice = autoCapSlice(hostCores, numClients);
+ capDecision = (fair >= MIN_AUTO_CAP_SLICE)
? "slice=" + slice
- : "skipped (slice<" + MIN_AUTO_CAP_SLICE + ")";
+ : "slice=" + slice + " (clamped; fair share would be " +
fair + ")";
}
String heapDecision;
if (userSetHeap(pipesConfig.getForkedJvmArgs())) {
@@ -669,27 +682,24 @@ public class PerClientServerManager implements
ServerManager {
// fight each other. We also reserve PARENT_RESERVED_CORES so the
parent
// JVM (which serializes requests, deserializes responses, runs
heartbeat
// bookkeeping) isn't starved for CPU.
- // Skip the auto-cap when the computed slice would drop below
- // MIN_AUTO_CAP_SLICE -- below that, the fork can't keep its socket
- // reader responsive and back-pressures the parent.
+ // The cap is clamped at MIN_AUTO_CAP_SLICE rather than skipped: a
fork that
+ // sees every core on an over-provisioned host is the expensive case.
if (!hasActiveProcessorCount) {
int hostCores = Runtime.getRuntime().availableProcessors();
- int forkBudget = Math.max(1, hostCores - PARENT_RESERVED_CORES);
- int slice = forkBudget / pipesConfig.getNumClients();
- if (slice >= MIN_AUTO_CAP_SLICE) {
- configArgs.add("-XX:ActiveProcessorCount=" + slice);
+ int numClients = pipesConfig.getNumClients();
+ int fair = Math.max(1, hostCores - PARENT_RESERVED_CORES) /
Math.max(1, numClients);
+ int slice = autoCapSlice(hostCores, numClients);
+ configArgs.add("-XX:ActiveProcessorCount=" + slice);
+ if (fair >= MIN_AUTO_CAP_SLICE) {
LOG.debug("clientId={}: auto-injected
-XX:ActiveProcessorCount={} " +
"(hostCores={}, parentReserved={}, numClients={})",
- clientId, slice, hostCores, PARENT_RESERVED_CORES,
- pipesConfig.getNumClients());
+ clientId, slice, hostCores, PARENT_RESERVED_CORES,
numClients);
} else {
- LOG.info("clientId={}: skipping -XX:ActiveProcessorCount
auto-cap " +
- "(would yield slice={} < MIN_AUTO_CAP_SLICE={}; " +
- "hostCores={}, parentReserved={}, numClients={}).{}",
- clientId, slice, MIN_AUTO_CAP_SLICE, hostCores,
- PARENT_RESERVED_CORES, pipesConfig.getNumClients(),
- pipesConfig.getNumClients() > 1
- ? " Consider lowering numClients on this
host." : "");
+ LOG.info("clientId={}: -XX:ActiveProcessorCount clamped to {}
" +
+ "(fair share would be {}; hostCores={},
parentReserved={}, " +
+ "numClients={}).{}", clientId, slice, fair, hostCores,
+ PARENT_RESERVED_CORES, numClients,
+ numClients > 1 ? " Consider lowering numClients on
this host." : "");
}
}
diff --git
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/PerClientServerManagerSizingTest.java
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/PerClientServerManagerSizingTest.java
index d1d9b6a74c..eb3cdb3167 100644
---
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/PerClientServerManagerSizingTest.java
+++
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/PerClientServerManagerSizingTest.java
@@ -75,13 +75,29 @@ public class PerClientServerManagerSizingTest {
@Test
public void cpuCapInjectedForSingleClient() throws Exception {
List<String> caps = withPrefix(commandLine(1),
"-XX:ActiveProcessorCount=");
- int slice = Math.max(1, Runtime.getRuntime().availableProcessors() -
2);
- if (slice >= 2) {
- assertEquals(List.of("-XX:ActiveProcessorCount=" + slice), caps);
- } else {
- // Host too small for the auto-cap; injection is skipped by design.
- assertTrue(caps.isEmpty(), "expected no auto-cap on tiny host: " +
caps);
- }
+ int slice = Math.max(PerClientServerManager.MIN_AUTO_CAP_SLICE,
+ Math.max(1, Runtime.getRuntime().availableProcessors() - 2));
+ assertEquals(List.of("-XX:ActiveProcessorCount=" + slice), caps);
+ }
+
+ /** The floor is a clamp, not a switch: an over-provisioned host still
caps every fork. */
+ @Test
+ public void cpuCapClampedWhenOverProvisioned() throws Exception {
+ // a thousand clients yields a fair share below the floor on any host
+ assertEquals(List.of("-XX:ActiveProcessorCount=" +
PerClientServerManager.MIN_AUTO_CAP_SLICE),
+ withPrefix(commandLine(1000), "-XX:ActiveProcessorCount="));
+ }
+
+ @Test
+ public void autoCapSlice() {
+ assertEquals(14, PerClientServerManager.autoCapSlice(16, 1));
+ assertEquals(2, PerClientServerManager.autoCapSlice(16, 7));
+ // 10 forks on 16 cores: fair share is 1, which used to disable the
cap entirely
+ assertEquals(2, PerClientServerManager.autoCapSlice(16, 10));
+ assertEquals(2, PerClientServerManager.autoCapSlice(4, 8));
+ assertEquals(2, PerClientServerManager.autoCapSlice(1, 1));
+ // numClients=0 is nonsense; treated as 1 rather than dividing by zero
+ assertEquals(14, PerClientServerManager.autoCapSlice(16, 0));
}
@Test