Skip to content

Commit 34515aa

Browse files
authored
TIKA-4821: warm-first pipes client selection; size single-fork JVMs correctly
2 parents f5ebbf9 + e80c539 commit 34515aa

8 files changed

Lines changed: 361 additions & 34 deletions

File tree

docs/modules/ROOT/pages/pipes/configuration.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ See also xref:pipes/timeouts.adoc[Timeouts] for the full timeout model.
7171

7272
|`socketTimeoutMillis`
7373
|`60000`
74-
|Maximum time (ms) to wait for data from a forked process. If no heartbeat or result is received within this window, the parse is considered hung.
74+
|Maximum time (ms) to wait for data from a forked process. If no heartbeat or result is received within this window, the parse is considered hung. Also serves as the fork's idle-shutdown timer: a fork that receives no work for this long exits and is restarted transparently on next use.
7575

7676
|`heartbeatIntervalMillis`
7777
|`1000`

docs/modules/ROOT/pages/pipes/cpu-sizing.adoc

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ cores.
2525

2626
To fix this, Tika Pipes auto-injects `-XX:ActiveProcessorCount` into each
2727
forked JVM's command line, sizing each fork's view of the CPU count to a fair
28-
slice of the host. This is on by default in per-client mode (`numClients > 1`)
29-
when the user has not already supplied `-XX:ActiveProcessorCount` in
30-
`forkedJvmArgs`.
28+
slice of the host. This is on by default in per-client mode — including
29+
`numClients=1`, where the slice is everything but the parent's reserved
30+
cores — whenever the user has not already supplied
31+
`-XX:ActiveProcessorCount` in `forkedJvmArgs`.
3132

3233
== Mental model
3334

@@ -73,10 +74,10 @@ For typical cloud-VM core counts:
7374
|===
7475
|hostCores |numClients |slice |Notes
7576

76-
|2 |1 |n/a |Tight; auto-cap not applied (single fork). Acceptable for low throughput.
77-
|4 |1 |n/a |Comfortable single-fork deployment.
77+
|2 |1 |1 → skipped |Tight; auto-cap declines. Acceptable for low throughput.
78+
|4 |1 |2 |Comfortable single-fork deployment.
7879
|4 |2 |1 → skipped |Auto-cap declines; consider `numClients=1`.
79-
|8 |1 |n/a |Lots of headroom; single-fork lifecycle isolation is fine.
80+
|8 |1 |6 |Lots of headroom; single-fork lifecycle isolation is fine.
8081
|8 |3 |2 |Sweet spot for medium pods.
8182
|16 |4 |3 |Sweet spot for 16-core hosts. Measured winner in benchmarks.
8283
|16 |6 |2 |Higher concurrency; tighter per-fork breathing room.
@@ -88,6 +89,26 @@ The general rule is: pick the largest `numClients` that satisfies
8889
`numClients × 2 + 2 ≤ hostCores`. Beyond that point, adding workers
8990
starts hurting throughput.
9091

92+
== Lazy start, warm reuse, and idle shutdown
93+
94+
`numClients` is a *ceiling* on concurrent forks, not a resident count:
95+
96+
* **Lazy start.** No fork is started at construction; each starts on the
97+
first request that needs it.
98+
* **Warm reuse (LIFO).** The client pool hands out the most-recently-used
99+
client first. Sequential or lightly-concurrent traffic stays on the same
100+
warm fork (or forks) instead of round-robining every fork awake; a cold
101+
fork is only started when concurrency actually exceeds the number of warm
102+
ones.
103+
* **Idle shutdown.** A fork that receives no work for `socketTimeoutMillis`
104+
(default 60s) exits on its own and is restarted transparently on next use.
105+
106+
Together these make the resident fork count track *recent concurrency*
107+
rather than `numClients`, so over-sizing `numClients` costs little at idle.
108+
Per-fork CPU and heap slices are still computed statically from
109+
`numClients` (a lone warm fork does not inherit its idle siblings'
110+
shares) — capacity planning above is unchanged.
111+
91112
== Diagnostics
92113

93114
Every `PipesParser` startup emits a one-shot summary line on its main
@@ -102,7 +123,6 @@ The `autoCap` field is one of:
102123

103124
* `slice=N` — the auto-cap fired; each fork sees N CPUs.
104125
* `skipped (slice<2)` — over-provisioned; operator should reduce `numClients`.
105-
* `n/a (single fork; not capped)` — `numClients=1`; fork sees the whole host.
106126
* `user-set in forkedJvmArgs` — operator set `-XX:ActiveProcessorCount` themselves.
107127

108128
Two `WARN`-level messages call out clearly-bad provisioning:
@@ -168,10 +188,11 @@ report `autoCap=user-set in forkedJvmArgs`.
168188

169189
Heap is auto-sized the same way CPU is. Left to itself, every forked JVM takes its own
170190
default max heap — a fixed fraction of host or container memory — so `numClients` forks have
171-
a combined ceiling well above what the host actually has. When `numClients > 1` and you have
172-
not set `-Xmx` (or `-XX:MaxRAMPercentage`/`-XX:MaxRAMFraction`) yourself, Tika injects
173-
`-XX:MaxRAMPercentage=75/numClients`, leaving the remainder for the parent JVM and the OS.
174-
The `pipes-cpu-sizing` summary line reports the decision as `heap=...`.
191+
a combined ceiling well above what the host actually has. When you have not set `-Xmx`
192+
(or `-XX:MaxRAMPercentage`/`-XX:MaxRAMFraction`) yourself, Tika injects
193+
`-XX:MaxRAMPercentage=75/numClients` — 75 for a single fork — leaving the remainder for
194+
the parent JVM and the OS. The `pipes-cpu-sizing` summary line reports the decision as
195+
`heap=...`.
175196

176197
[IMPORTANT]
177198
====
@@ -187,6 +208,12 @@ its own heap at startup and logs a `WARN` if it came up under 256 MB — the poi
187208
ordinary documents, not just pathological ones, begin to fail. If you see that warning,
188209
lower `numClients`, raise the container memory limit, or set `-Xmx` explicitly.
189210
211+
When you set fork heap explicitly, the parent checks the arithmetic for you at startup:
212+
if `numClients × -Xmx` exceeds 75% of host/container memory (or explicit
213+
`-XX:MaxRAMPercentage` values sum past 75%), it logs a `pipes-cpu-sizing` `WARN` naming
214+
the commitment. It warns rather than fails — a co-tenant box the operator has budgeted
215+
deliberately is indistinguishable from a mistake.
216+
190217
Cross-check both constraints yourself when sizing: `numClients × 2 + 2 ≤ hostCores` **and**
191218
`numClients × per-worker-heap ≤ 75% of memory`.
192219
====

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

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,87 @@ private static int forkHeapPercentage(int numClients) {
8080
return Math.max(1, FORK_HEAP_BUDGET_PERCENT / numClients);
8181
}
8282

83+
/**
84+
* Explicit heap settings suppress auto-division, so nothing else checks that
85+
* numClients forks at the user's size can actually coexist -- the mistake only
86+
* surfaces later as fork OOMs or the host OOM-killer. Returns a warning when
87+
* the combined explicit ceiling exceeds the fork budget, else null.
88+
* Package-private for tests.
89+
*/
90+
static String heapOvercommitWarning(List<String> forkedJvmArgs, int numClients,
91+
long totalMemBytes) {
92+
long xmxBytes = -1;
93+
double maxRamPct = -1;
94+
// last occurrence wins, same as the JVM
95+
for (String arg : forkedJvmArgs) {
96+
if (arg.startsWith("-Xmx")) {
97+
xmxBytes = parseJvmMemArg(arg.substring("-Xmx".length()));
98+
} else if (arg.startsWith("-XX:MaxRAMPercentage=")) {
99+
try {
100+
maxRamPct = Double.parseDouble(
101+
arg.substring("-XX:MaxRAMPercentage=".length()));
102+
} catch (NumberFormatException e) {
103+
maxRamPct = -1;
104+
}
105+
}
106+
}
107+
// -Xmx beats MaxRAMPercentage in the JVM, so only judge the percentage alone
108+
if (xmxBytes <= 0 && maxRamPct > 0 && numClients * maxRamPct > FORK_HEAP_BUDGET_PERCENT) {
109+
return String.format(java.util.Locale.ROOT,
110+
"numClients=%d x -XX:MaxRAMPercentage=%s commits %.0f%% of memory; " +
111+
"the parent JVM and OS need the remainder (fork budget: %d%%). " +
112+
"Lower numClients or the percentage.",
113+
numClients, maxRamPct, numClients * maxRamPct, FORK_HEAP_BUDGET_PERCENT);
114+
}
115+
if (xmxBytes > 0 && totalMemBytes > 0
116+
&& numClients * (double) xmxBytes > totalMemBytes * FORK_HEAP_BUDGET_PERCENT / 100.0) {
117+
return String.format(java.util.Locale.ROOT,
118+
"numClients=%d x -Xmx (%,dMB each) commits %,dMB of %,dMB total memory, " +
119+
"over the %d%% fork budget; the parent JVM and OS need the remainder. " +
120+
"Lower numClients or -Xmx.",
121+
numClients, xmxBytes / MB, numClients * xmxBytes / MB, totalMemBytes / MB,
122+
FORK_HEAP_BUDGET_PERCENT);
123+
}
124+
return null;
125+
}
126+
127+
private static final long MB = 1024 * 1024;
128+
129+
// "-Xmx" style value: digits with optional k/m/g/t suffix; -1 if unparseable
130+
static long parseJvmMemArg(String value) {
131+
if (value == null || value.isEmpty()) {
132+
return -1;
133+
}
134+
long multiplier = switch (Character.toLowerCase(value.charAt(value.length() - 1))) {
135+
case 'k' -> 1024L;
136+
case 'm' -> 1024L * 1024;
137+
case 'g' -> 1024L * 1024 * 1024;
138+
case 't' -> 1024L * 1024 * 1024 * 1024;
139+
default -> 1;
140+
};
141+
String digits = multiplier == 1 ? value : value.substring(0, value.length() - 1);
142+
try {
143+
long n = Long.parseLong(digits);
144+
return n <= 0 ? -1 : Math.multiplyExact(n, multiplier);
145+
} catch (NumberFormatException | ArithmeticException e) {
146+
return -1;
147+
}
148+
}
149+
150+
// Container-aware on JDK 17 (honors cgroup memory limits); -1 if unavailable.
151+
// Read via JMX attribute name: the typed accessor lives on a com.sun
152+
// interface that forbiddenapis bans as non-portable.
153+
private static long totalMemorySize() {
154+
try {
155+
Object value = java.lang.management.ManagementFactory.getPlatformMBeanServer()
156+
.getAttribute(new javax.management.ObjectName("java.lang:type=OperatingSystem"),
157+
"TotalMemorySize");
158+
return value instanceof Number n ? n.longValue() : -1;
159+
} catch (Exception e) {
160+
return -1;
161+
}
162+
}
163+
83164

84165
private final PipesConfig pipesConfig;
85166
private final Path tikaConfigPath;
@@ -144,8 +225,6 @@ private void logCpuSizing() {
144225
String capDecision;
145226
if (userSetCap) {
146227
capDecision = "user-set in forkedJvmArgs";
147-
} else if (numClients <= 1) {
148-
capDecision = "n/a (single fork; not capped)";
149228
} else {
150229
int budget = Math.max(1, hostCores - PARENT_RESERVED_CORES);
151230
int slice = budget / numClients;
@@ -156,14 +235,18 @@ private void logCpuSizing() {
156235
String heapDecision;
157236
if (userSetHeap(pipesConfig.getForkedJvmArgs())) {
158237
heapDecision = "user-set in forkedJvmArgs";
159-
} else if (numClients <= 1) {
160-
heapDecision = "n/a (single fork; JVM default)";
161238
} else {
162239
heapDecision = "MaxRAMPercentage=" + forkHeapPercentage(numClients);
163240
}
164241
LOG.info("pipes-cpu-sizing: hostCores={}, numClients={}, parentReserved={}, " +
165242
"autoCap={}, heap={}", hostCores, numClients, PARENT_RESERVED_CORES,
166243
capDecision, heapDecision);
244+
245+
String overcommit = heapOvercommitWarning(pipesConfig.getForkedJvmArgs(),
246+
numClients, totalMemorySize());
247+
if (overcommit != null) {
248+
LOG.warn("pipes-cpu-sizing: {}", overcommit);
249+
}
167250
}
168251

169252
@Override
@@ -313,7 +396,7 @@ private synchronized void startServer() throws IOException, InterruptedException
313396
LOG.trace("clientId={}: starting server on port={}", clientId, port);
314397

315398
tmpDir = Files.createTempDirectory("pipes-server-" + clientId + "-");
316-
ProcessBuilder pb = new ProcessBuilder(getCommandline());
399+
ProcessBuilder pb = new ProcessBuilder(getCommandline(tmpDir));
317400
// Tell the child our PID so it can watch ProcessHandle.onExit() and
318401
// self-terminate promptly if we die. Without this, an orphan child
319402
// can only notice via socket-read timeout (default 60s) and can't
@@ -448,7 +531,9 @@ private void deleteDir(Path dir) {
448531
}
449532
}
450533

451-
private String[] getCommandline() throws IOException {
534+
// package-private and tmpDir passed in (rather than read from the field set by
535+
// startServer) so tests can exercise arg injection without forking a JVM
536+
String[] getCommandline(Path tmpDir) throws IOException {
452537
List<String> configArgs = new ArrayList<>(pipesConfig.getForkedJvmArgs());
453538
boolean hasClassPath = false;
454539
boolean hasHeadless = false;
@@ -500,7 +585,7 @@ private String[] getCommandline() throws IOException {
500585
// host has -- the same "each JVM thinks it owns the machine" problem the
501586
// ActiveProcessorCount cap solves. Give each fork a slice of a fixed budget
502587
// instead, leaving the remainder for the parent and the OS.
503-
if (!userSetHeap(configArgs) && pipesConfig.getNumClients() > 1) {
588+
if (!userSetHeap(configArgs)) {
504589
int pct = forkHeapPercentage(pipesConfig.getNumClients());
505590
configArgs.add("-XX:MaxRAMPercentage=" + pct);
506591
LOG.debug("clientId={}: auto-injected -XX:MaxRAMPercentage={} (numClients={})",
@@ -517,7 +602,7 @@ private String[] getCommandline() throws IOException {
517602
// Skip the auto-cap when the computed slice would drop below
518603
// MIN_AUTO_CAP_SLICE -- below that, the fork can't keep its socket
519604
// reader responsive and back-pressures the parent.
520-
if (!hasActiveProcessorCount && pipesConfig.getNumClients() > 1) {
605+
if (!hasActiveProcessorCount) {
521606
int hostCores = Runtime.getRuntime().availableProcessors();
522607
int forkBudget = Math.max(1, hostCores - PARENT_RESERVED_CORES);
523608
int slice = forkBudget / pipesConfig.getNumClients();
@@ -530,10 +615,11 @@ private String[] getCommandline() throws IOException {
530615
} else {
531616
LOG.info("clientId={}: skipping -XX:ActiveProcessorCount auto-cap " +
532617
"(would yield slice={} < MIN_AUTO_CAP_SLICE={}; " +
533-
"hostCores={}, parentReserved={}, numClients={}). " +
534-
"Consider lowering numClients on this host.",
618+
"hostCores={}, parentReserved={}, numClients={}).{}",
535619
clientId, slice, MIN_AUTO_CAP_SLICE, hostCores,
536-
PARENT_RESERVED_CORES, pipesConfig.getNumClients());
620+
PARENT_RESERVED_CORES, pipesConfig.getNumClients(),
621+
pipesConfig.getNumClients() > 1
622+
? " Consider lowering numClients on this host." : "");
537623
}
538624
}
539625

@@ -547,7 +633,7 @@ private String[] getCommandline() throws IOException {
547633
commandLine.add(ProcessUtils.escapeCommandLine(javaPath));
548634

549635
if (!hasClassPath) {
550-
Path argFile = writeArgFile();
636+
Path argFile = writeArgFile(tmpDir);
551637
commandLine.add("@" + argFile.toAbsolutePath());
552638
}
553639

@@ -573,7 +659,7 @@ private String[] getCommandline() throws IOException {
573659
return commandLine.toArray(new String[0]);
574660
}
575661

576-
private Path writeArgFile() throws IOException {
662+
private Path writeArgFile(Path tmpDir) throws IOException {
577663
Path argFile = tmpDir.resolve("jvm-args.txt");
578664
// forward any tika.extras.dir jars to the forked PipesServer
579665
String classpath = TikaExtras.appendJarsToClasspath(System.getProperty("java.class.path"));

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ public int getNumClients() {
280280
}
281281

282282
public void setNumClients(int numClients) {
283-
// Without this, 0 surfaces at startup as ArrayBlockingQueue's message-less IAE.
283+
// Without this, 0 surfaces at startup as the client queue's message-less IAE.
284284
if (numClients <= 0) {
285285
throw new IllegalArgumentException("numClients must be > 0, was " + numClients);
286286
}

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import java.nio.file.Path;
2222
import java.util.ArrayList;
2323
import java.util.List;
24-
import java.util.concurrent.ArrayBlockingQueue;
24+
import java.util.concurrent.LinkedBlockingDeque;
2525
import java.util.concurrent.TimeUnit;
2626

2727
import org.slf4j.Logger;
@@ -80,14 +80,17 @@ public static PipesParser load(TikaJsonConfig tikaJsonConfig, PipesConfig pipesC
8080
private final Path tikaConfigPath;
8181
private final List<PipesClient> clients = new ArrayList<>();
8282
private final List<ServerManager> serverManagers = new ArrayList<>();
83-
private final ArrayBlockingQueue<PipesClient> clientQueue;
83+
// LIFO: the most-recently-returned client is borrowed next, so light traffic
84+
// concentrates on warm forks instead of round-robining every fork awake (and,
85+
// at intervals over the idle shutdown, cold-starting on every request).
86+
private final LinkedBlockingDeque<PipesClient> clientQueue;
8487
private final boolean isSharedMode;
8588

8689
private PipesParser(PipesConfig pipesConfig, Path tikaConfigPath) {
8790
this.pipesConfig = pipesConfig;
8891
this.tikaConfigPath = tikaConfigPath;
8992
this.isSharedMode = pipesConfig.isUseSharedServer();
90-
this.clientQueue = new ArrayBlockingQueue<>(pipesConfig.getNumClients());
93+
this.clientQueue = new LinkedBlockingDeque<>(pipesConfig.getNumClients());
9194

9295
if (isSharedMode) {
9396
// Shared mode: one ServerManager for all clients
@@ -98,7 +101,7 @@ private PipesParser(PipesConfig pipesConfig, Path tikaConfigPath) {
98101

99102
for (int i = 0; i < pipesConfig.getNumClients(); i++) {
100103
PipesClient client = new PipesClient(pipesConfig, sharedManager);
101-
clientQueue.offer(client);
104+
clientQueue.offerLast(client);
102105
clients.add(client);
103106
}
104107
} else {
@@ -110,7 +113,7 @@ private PipesParser(PipesConfig pipesConfig, Path tikaConfigPath) {
110113
serverManagers.add(serverManager);
111114

112115
PipesClient client = new PipesClient(pipesConfig, serverManager);
113-
clientQueue.offer(client);
116+
clientQueue.offerLast(client);
114117
clients.add(client);
115118
}
116119
}
@@ -120,15 +123,15 @@ public PipesResult parse(FetchEmitTuple t) throws InterruptedException,
120123
PipesException, IOException {
121124
PipesClient client = null;
122125
try {
123-
client = clientQueue.poll(pipesConfig.getMaxWaitForClientMillis(),
126+
client = clientQueue.pollFirst(pipesConfig.getMaxWaitForClientMillis(),
124127
TimeUnit.MILLISECONDS);
125128
if (client == null) {
126129
return PipesResults.CLIENT_UNAVAILABLE_WITHIN_MS;
127130
}
128131
return client.process(t);
129132
} finally {
130133
if (client != null) {
131-
clientQueue.offer(client);
134+
clientQueue.offerFirst(client);
132135
}
133136
}
134137
}
@@ -160,6 +163,11 @@ public void close() throws IOException {
160163
}
161164
}
162165

166+
// package-private for tests: how many forked servers are actually live
167+
long startedServerCount() {
168+
return serverManagers.stream().filter(ServerManager::isRunning).count();
169+
}
170+
163171
/**
164172
* Returns whether this parser is using shared server mode.
165173
*

0 commit comments

Comments
 (0)