Skip to content

Commit f1f7532

Browse files
authored
TIKA-4809 stage 6 (#3006)
* TIKA-4809: Add maxRequestSizeBytes * TIKA-4809: Bound the request path -- spool lifetime, fork heap, temp-file suffix * TIKA-4809: Derive numClients from cores when unset
1 parent 0b84d33 commit f1f7532

11 files changed

Lines changed: 441 additions & 47 deletions

File tree

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,35 @@ report `autoCap=user-set in forkedJvmArgs`.
166166
[#heap-per-worker]
167167
== Heap per worker — rule of thumb
168168

169+
Heap is auto-sized the same way CPU is. Left to itself, every forked JVM takes its own
170+
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=...`.
175+
176+
[IMPORTANT]
177+
====
178+
**`numClients` is sized against CPU, not memory.** The rule above —
179+
`numClients × 2 + 2 ≤ hostCores` — considers cores only. Memory is then divided among
180+
however many workers that produced. On a host with many cores relative to its RAM, a
181+
`numClients` that is correct for CPU can leave each fork with too little heap to parse
182+
reliably.
183+
184+
Tika cannot reconcile the two automatically: the parent sizes forks as a *percentage* of
185+
memory and has no portable way to resolve that to bytes. Each forked JVM therefore checks
186+
its own heap at startup and logs a `WARN` if it came up under 256 MB — the point below which
187+
ordinary documents, not just pathological ones, begin to fail. If you see that warning,
188+
lower `numClients`, raise the container memory limit, or set `-Xmx` explicitly.
189+
190+
Cross-check both constraints yourself when sizing: `numClients × 2 + 2 ≤ hostCores` **and**
191+
`numClients × per-worker-heap ≤ 75% of memory`.
192+
====
193+
194+
Set `-Xmx` explicitly when you know your workload: the auto-slice is a safe default, not a
195+
tuned one, and a fork that legitimately needs more than its slice will OOM where an untuned
196+
JVM might have grown into spare memory.
197+
169198
A reasonable starting point is **~2 GB of heap per forked worker** (passed via `-Xmx2g` in `forkedJvmArgs`). The number falls out of three independent constraints any of which can dominate:
170199

171200
* **Worst-case PDF parsing.** A handful of pathological PDFs in any reasonably large corpus will allocate hundreds of MB of intermediate object data per document — large image streams, deeply nested form fields, big embedded fonts. Smaller heaps OOM on those documents; larger heaps just let GC clean up between docs.

docs/modules/ROOT/pages/using-tika/server/index.adoc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,10 @@ Server behavior beyond host/port is controlled by a JSON config file passed via
308308
|`false`
309309
|Include parser stack traces in error responses. Useful in dev, dangerous in production (leaks internals).
310310

311+
|`maxRequestSizeBytes`
312+
|`-1` (no limit)
313+
|Maximum request body in bytes; larger requests are rejected with `413`. Enforced for chunked uploads too, not just those declaring a `Content-Length`. Uploads are spooled to disk, so leaving this unset lets a caller fill the temp directory.
314+
311315
|`logLevel`
312316
|_inherited_
313317
|`debug` or `info` to override the runtime log level.

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,22 @@ public class PerClientServerManager implements ServerManager {
6565
* formula could otherwise produce slice=1. */
6666
private static final int MIN_AUTO_CAP_SLICE = 2;
6767

68+
/** Share of host/container memory the forks may collectively claim; the remainder is
69+
* left for the parent JVM, the OS, and page cache for spooled input. */
70+
private static final int FORK_HEAP_BUDGET_PERCENT = 75;
71+
72+
73+
private static boolean userSetHeap(List<String> args) {
74+
return args.stream().anyMatch(a -> a.startsWith("-Xmx")
75+
|| a.startsWith("-XX:MaxRAMPercentage")
76+
|| a.startsWith("-XX:MaxRAMFraction"));
77+
}
78+
79+
private static int forkHeapPercentage(int numClients) {
80+
return Math.max(1, FORK_HEAP_BUDGET_PERCENT / numClients);
81+
}
82+
83+
6884
private final PipesConfig pipesConfig;
6985
private final Path tikaConfigPath;
7086
private final int clientId;
@@ -134,8 +150,17 @@ private void logCpuSizing() {
134150
? "slice=" + slice
135151
: "skipped (slice<" + MIN_AUTO_CAP_SLICE + ")";
136152
}
153+
String heapDecision;
154+
if (userSetHeap(pipesConfig.getForkedJvmArgs())) {
155+
heapDecision = "user-set in forkedJvmArgs";
156+
} else if (numClients <= 1) {
157+
heapDecision = "n/a (single fork; JVM default)";
158+
} else {
159+
heapDecision = "MaxRAMPercentage=" + forkHeapPercentage(numClients);
160+
}
137161
LOG.info("pipes-cpu-sizing: hostCores={}, numClients={}, parentReserved={}, " +
138-
"autoCap={}", hostCores, numClients, PARENT_RESERVED_CORES, capDecision);
162+
"autoCap={}, heap={}", hostCores, numClients, PARENT_RESERVED_CORES,
163+
capDecision, heapDecision);
139164
}
140165

141166
@Override
@@ -442,6 +467,19 @@ private String[] getCommandline() throws IOException {
442467
.toAbsolutePath());
443468
}
444469

470+
// Heap gets the same treatment as CPU. Left alone, every fork independently
471+
// takes the JVM's own default max heap (a fixed fraction of host/container
472+
// memory), so numClients forks have a combined ceiling well above what the
473+
// host has -- the same "each JVM thinks it owns the machine" problem the
474+
// ActiveProcessorCount cap solves. Give each fork a slice of a fixed budget
475+
// instead, leaving the remainder for the parent and the OS.
476+
if (!userSetHeap(configArgs) && pipesConfig.getNumClients() > 1) {
477+
int pct = forkHeapPercentage(pipesConfig.getNumClients());
478+
configArgs.add("-XX:MaxRAMPercentage=" + pct);
479+
LOG.debug("clientId={}: auto-injected -XX:MaxRAMPercentage={} (numClients={})",
480+
clientId, pct, pipesConfig.getNumClients());
481+
}
482+
445483
// If the user hasn't explicitly set -XX:ActiveProcessorCount, size each
446484
// forked JVM's view of CPUs to a fair slice of the host. Otherwise each
447485
// JVM defaults its GC, JIT, and common ForkJoinPool to "all cores", which

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,23 @@ public class PipesConfig {
3232

3333
public static final long DEFAULT_SHUTDOWN_CLIENT_AFTER_MILLS = 300000;
3434

35-
public static final int DEFAULT_NUM_CLIENTS = 4;
35+
/** Past this, worker count becomes a memory decision, and memory is not visible here. */
36+
public static final int MAX_AUTO_NUM_CLIENTS = 4;
37+
38+
private static final int PARENT_RESERVED_CORES = 2;
39+
private static final int MIN_CORES_PER_CLIENT = 2;
40+
41+
/**
42+
* Worker count when the operator has not chosen one. CPU-derived, so the default
43+
* satisfies Tika's own sizing rule on any host; a fixed 4 needs 10 cores and would
44+
* warn about itself on smaller ones. Memory cannot participate -- no Java SE API
45+
* exposes container memory -- so each fork checks its own heap at startup instead.
46+
*/
47+
public static int defaultNumClients() {
48+
int hostCores = Runtime.getRuntime().availableProcessors();
49+
int byCores = (hostCores - PARENT_RESERVED_CORES) / MIN_CORES_PER_CLIENT;
50+
return Math.max(1, Math.min(byCores, MAX_AUTO_NUM_CLIENTS));
51+
}
3652

3753
public static final int DEFAULT_MAX_FILES_PROCESSED_PER_PROCESS = 10000;
3854

@@ -66,7 +82,7 @@ public class PipesConfig {
6682
private long heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS;
6783

6884
private long shutdownClientAfterMillis = DEFAULT_SHUTDOWN_CLIENT_AFTER_MILLS;
69-
private int numClients = DEFAULT_NUM_CLIENTS;
85+
private int numClients = defaultNumClients();
7086

7187
private long maxWaitForClientMillis = DEFAULT_MAX_WAIT_FOR_CLIENT_MS;
7288
private int maxFilesProcessedPerProcess = DEFAULT_MAX_FILES_PROCESSED_PER_PROCESS;

tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,8 +572,27 @@ private static void watchParentProcess() {
572572
LOG.info("watching parent pid {} for exit", parentPid);
573573
}
574574

575+
/** Below this, ordinary documents -- not just pathological ones -- start OOMing. */
576+
private static final long MIN_USABLE_HEAP_BYTES = 256L * 1024 * 1024;
577+
578+
/** Checked here, not in the parent: the parent sizes forks by percentage and has no
579+
* portable way to resolve that to bytes. The child knows what it actually got. */
580+
private static void checkUsableHeap() {
581+
long maxHeapMb = Runtime.getRuntime().maxMemory() / (1024 * 1024);
582+
LOG.info("forked JVM max heap: {} MB", maxHeapMb);
583+
if (maxHeapMb < MIN_USABLE_HEAP_BYTES / (1024 * 1024)) {
584+
LOG.warn("forked JVM max heap is {} MB, below the {} MB needed to parse " +
585+
"reliably. Lower pipes.numClients, raise the container memory " +
586+
"limit, or set -Xmx explicitly in forkedJvmArgs; otherwise " +
587+
"ordinary documents will fail with OOM.",
588+
maxHeapMb, MIN_USABLE_HEAP_BYTES / (1024 * 1024));
589+
}
590+
}
591+
575592
protected void initializeResources() throws TikaException, IOException, SAXException {
576593

594+
checkUsableHeap();
595+
577596
TikaJsonConfig tikaJsonConfig = tikaLoader.getConfig();
578597
TikaPluginManager tikaPluginManager = TikaPluginManager.load(tikaJsonConfig);
579598

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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.server.core;
18+
19+
import java.io.FilterInputStream;
20+
import java.io.IOException;
21+
import java.io.InputStream;
22+
23+
import jakarta.ws.rs.container.ContainerRequestContext;
24+
import jakarta.ws.rs.container.ContainerRequestFilter;
25+
import jakarta.ws.rs.core.MediaType;
26+
import jakarta.ws.rs.core.Response;
27+
import jakarta.ws.rs.ext.Provider;
28+
29+
/**
30+
* Rejects request bodies larger than {@code maxRequestSizeBytes}.
31+
* <p>
32+
* A declared Content-Length over the limit is refused before the body is read. Requests
33+
* without a usable Content-Length -- chunked transfer encoding, in particular -- are
34+
* counted as they are consumed, so the limit holds whether or not the client is honest
35+
* about the size.
36+
*/
37+
@Provider
38+
public class MaxRequestSizeFilter implements ContainerRequestFilter {
39+
40+
static final String TOO_LARGE_MESSAGE = "Request body exceeds maxRequestSizeBytes";
41+
42+
private final long maxRequestSizeBytes;
43+
44+
/**
45+
* @param maxRequestSizeBytes maximum request body in bytes; negative disables the limit
46+
*/
47+
public MaxRequestSizeFilter(long maxRequestSizeBytes) {
48+
this.maxRequestSizeBytes = maxRequestSizeBytes;
49+
}
50+
51+
@Override
52+
public void filter(ContainerRequestContext requestContext) {
53+
if (maxRequestSizeBytes < 0) {
54+
return;
55+
}
56+
if (requestContext.getLength() > maxRequestSizeBytes) {
57+
requestContext.abortWith(tooLarge());
58+
return;
59+
}
60+
requestContext.setEntityStream(
61+
new BoundedInputStream(requestContext.getEntityStream(), maxRequestSizeBytes));
62+
}
63+
64+
private static Response tooLarge() {
65+
return Response
66+
.status(Response.Status.REQUEST_ENTITY_TOO_LARGE)
67+
.entity(TOO_LARGE_MESSAGE)
68+
.type(MediaType.TEXT_PLAIN)
69+
.build();
70+
}
71+
72+
/**
73+
* Throws once more than {@code limit} bytes have been read. Deliberately not
74+
* silent truncation: a caller that sent too much must not receive a 200 describing
75+
* a prefix of their document.
76+
*/
77+
private static final class BoundedInputStream extends FilterInputStream {
78+
79+
private final long limit;
80+
private long count;
81+
82+
private BoundedInputStream(InputStream in, long limit) {
83+
super(in);
84+
this.limit = limit;
85+
}
86+
87+
@Override
88+
public int read() throws IOException {
89+
int c = super.read();
90+
if (c != -1) {
91+
add(1);
92+
}
93+
return c;
94+
}
95+
96+
@Override
97+
public int read(byte[] b, int off, int len) throws IOException {
98+
int read = super.read(b, off, len);
99+
if (read > 0) {
100+
add(read);
101+
}
102+
return read;
103+
}
104+
105+
private void add(int n) throws IOException {
106+
count += n;
107+
if (count > limit) {
108+
throw new IOException(TOO_LARGE_MESSAGE + " (" + limit + ")");
109+
}
110+
}
111+
}
112+
}

tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ public class TikaServerConfig {
6969
private boolean allowPerRequestConfig = false;
7070
private String cors = "";
7171
private boolean returnStackTrace = false;
72+
private long maxRequestSizeBytes = -1;
7273
private String id = UUID
7374
.randomUUID()
7475
.toString();
@@ -240,6 +241,18 @@ public void setConfigPath(String path) {
240241
this.configPath = Paths.get(path);
241242
}
242243

244+
/**
245+
* Maximum request body in bytes. Negative (the default) means no limit; tika-server
246+
* spools uploads to disk, so an unbounded value lets a caller fill the temp directory.
247+
*/
248+
public long getMaxRequestSizeBytes() {
249+
return maxRequestSizeBytes;
250+
}
251+
252+
public void setMaxRequestSizeBytes(long maxRequestSizeBytes) {
253+
this.maxRequestSizeBytes = maxRequestSizeBytes;
254+
}
255+
243256
public boolean isReturnStackTrace() {
244257
return returnStackTrace;
245258
}

tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ private static void loadAllProviders(TikaServerConfig tikaServerConfig, ServerSt
345345

346346
// Add ConfigEndpointSecurityFilter to gate /config endpoints
347347
writers.add(new ConfigEndpointSecurityFilter(tikaServerConfig.isAllowPerRequestConfig()));
348+
writers.add(new MaxRequestSizeFilter(tikaServerConfig.getMaxRequestSizeBytes()));
348349

349350
// setRequestLogLevel rejects anything but debug/info, so no validation needed here.
350351
TikaLoggingFilter logFilter = null;
@@ -672,7 +673,7 @@ private static Path createServerConfig(Path existingConfigPath,
672673
// Only set default pipes config if there's no existing config
673674
// This allows user-provided config to specify their own numClients, etc.
674675
if (existingConfigPath == null || !Files.exists(existingConfigPath)) {
675-
builder.setPipesConfig(4, null);
676+
builder.setPipesConfig(PipesConfig.defaultNumClients(), null);
676677
}
677678

678679
// Add unpack emitter if /unpack endpoint is enabled

tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,21 +190,46 @@ public List<Metadata> parse(TikaInputStream tis, Metadata metadata,
190190
}
191191
}
192192

193+
/** Longest suffix carried over from a client filename; keeps well clear of NAME_MAX. */
194+
private static final int MAX_SUFFIX_LENGTH = 20;
195+
193196
/**
194-
* Extracts file suffix from metadata (resource name or content-type).
197+
* Extracts a file suffix from the resource name for the spool file.
198+
* <p>
199+
* The resource name is client-supplied ({@code Content-Disposition} / {@code File-Name}),
200+
* so the suffix is sanitized here rather than left for {@code Files.createTempFile} to
201+
* reject: a suffix containing a path separator makes it throw {@code IllegalArgumentException}
202+
* — not a traversal, since the JDK refuses it, but an uncaught 500 driven by a request
203+
* header. An over-long suffix likewise fails at the filesystem. The suffix is a parser
204+
* hint, so anything unusable is simply dropped in favour of {@code .tmp}.
195205
*/
196206
private String getSuffix(Metadata metadata) {
197207
String resourceName = metadata.get(TikaCoreProperties.RESOURCE_NAME_KEY);
198208
if (resourceName != null) {
199209
int lastDot = resourceName.lastIndexOf('.');
200210
if (lastDot > 0 && lastDot < resourceName.length() - 1) {
201-
return resourceName.substring(lastDot);
211+
String suffix = resourceName.substring(lastDot);
212+
if (isUsableSuffix(suffix)) {
213+
return suffix;
214+
}
202215
}
203216
}
204-
// Default suffix
205217
return ".tmp";
206218
}
207219

220+
private static boolean isUsableSuffix(String suffix) {
221+
if (suffix.length() > MAX_SUFFIX_LENGTH) {
222+
return false;
223+
}
224+
for (int i = 0; i < suffix.length(); i++) {
225+
char c = suffix.charAt(i);
226+
if (c == '/' || c == '\\' || c == '' || Character.isISOControl(c)) {
227+
return false;
228+
}
229+
}
230+
return true;
231+
}
232+
208233
/**
209234
* Builds a JSON error response carrying a subset of the {@code PipesResult}
210235
* serialization. By default the body is just {@code {"status": "TIMEOUT"}}. The

0 commit comments

Comments
 (0)