Skip to content

Commit 7a79fd2

Browse files
TIKA-4793: make the Pipes IPC payload limit configurable (#3009)
Adds maxIpcPayloadBytes (default 100 MiB) and a server-side BoundedOutputStream guard so an oversized result fails as PAYLOAD_LIMIT_EXCEEDED instead of OOMing the worker. Preserves already-emitted statuses on overflow and fixes archive sizing. Closes #3009 Co-authored-by: Tim Allison <tallison@apache.org>
1 parent 0882e4b commit 7a79fd2

11 files changed

Lines changed: 563 additions & 31 deletions

File tree

CHANGES.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ Release 4.0.0 - ???
111111

112112
OTHER CHANGES
113113

114+
* PipesClient/PipesServer IPC now enforces a configurable payload limit
115+
(pipes.maxIpcPayloadBytes, default 100 MB) in both directions. Results
116+
that exceed the limit return PAYLOAD_LIMIT_EXCEEDED instead of causing
117+
heap exhaustion; crash messages are also size-capped (TIKA-4793).
118+
114119
* MagicDetector now compiles its regular expression once, in the
115120
constructor, instead of recompiling it on every match (TIKA-4796).
116121

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,17 @@ These settings control how parsed results are batched before sending to emitters
138138
|When `false`, only successfully-parsed tuples reach the emitter — files that crash, time out, or otherwise fail are dropped from the output. When `true`, every tuple is emitted, including failures (the metadata carries the exception). Turn this on if you need a complete record of what was attempted (audit, retry logic, chaos-monkey tests).
139139
|===
140140

141+
== IPC Payload Limit
142+
143+
[cols="1,1,3"]
144+
|===
145+
|Field |Default |Description
146+
147+
|`maxIpcPayloadBytes`
148+
|`104857600` (100 MB)
149+
|Maximum size in bytes of a single IPC message between the client and the forked server. This limit is *bidirectional*: it applies both to parse results returned from the server (FINISHED) and to requests sent from the client (NEW_REQUEST). Raising it lets very large documents pass over IPC; set the forked JVM `-Xmx` to at least approximately 3× this value to keep heap usage under control. Setting it too small (below the size of a typical `FetchEmitTuple`) will cause requests to be rejected silently as `UNSPECIFIED_CRASH`. The minimum accepted value is the serialized size of a `PAYLOAD_LIMIT_EXCEEDED` response (a few dozen bytes); values below that are rejected at config load time.
150+
|===
151+
141152
== Emit Strategy
142153

143154
`emitStrategy` controls whether parsed extracts are emitted directly from the forked PipesServer or passed back to the parent process first. The default is balanced for typical workloads — tune only if you have a memory or throughput problem.

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,16 @@ private PipesResult waitForServer(FetchEmitTuple t, IntermediateResult intermedi
421421
if (result.emitData() instanceof EmitDataImpl emitDataImpl) {
422422
emitDataImpl.setParseContext(t.getParseContext());
423423
}
424+
// The server's static PAYLOAD_LIMIT_EXCEEDED fallback frame carries null
425+
// emitData/emitKey. AsyncEmitter silently skips null-emitData results, so
426+
// the document would disappear from the audit trail. Rebuild with the
427+
// original emit key using what partial metadata we have.
428+
if (result.emitData() == null
429+
&& result.status() == PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED) {
430+
return buildFatalResult(t.getId(), t.getEmitKey(),
431+
PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED,
432+
intermediateResult.get());
433+
}
424434
return result;
425435
default:
426436
throw new IOException("Unexpected message type from server: " + msg.type());

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

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.apache.tika.pipes.api.FetchEmitTuple;
3131
import org.apache.tika.pipes.api.ParseMode;
3232
import org.apache.tika.pipes.core.protocol.PipesMessage;
33+
import org.apache.tika.pipes.core.server.ServerProtocolIO;
3334

3435
public class PipesConfig {
3536

@@ -546,18 +547,26 @@ public int getMaxIpcPayloadBytes() {
546547
}
547548

548549
/**
549-
* Sets the maximum IPC payload size in bytes. Must be a positive value.
550-
* This bounds the size of a message the client will accept back from the
551-
* forked server (chiefly the FINISHED result). Request payloads
552-
* (client to server) are small and use the built-in default.
550+
* Sets the maximum IPC payload size in bytes. This limit is <em>bidirectional</em>:
551+
* it controls both the largest result the client will accept back from the forked server
552+
* (the FINISHED payload) and the largest request the server will accept from the client
553+
* (the NEW_REQUEST payload). Lowering this value below the size of a typical
554+
* {@link org.apache.tika.pipes.api.FetchEmitTuple} will cause requests to be rejected
555+
* on the server side and reported as undiagnosable {@code UNSPECIFIED_CRASH} errors.
556+
* <p>
557+
* The value must be at least {@link org.apache.tika.pipes.core.server.ServerProtocolIO#MIN_FALLBACK_PAYLOAD_BYTES}
558+
* so that the server can always write a {@code PAYLOAD_LIMIT_EXCEEDED} response
559+
* that the client will accept.
553560
*
554-
* @param maxIpcPayloadBytes positive payload limit in bytes
555-
* @throws IllegalArgumentException if the value is not positive
561+
* @param maxIpcPayloadBytes payload limit in bytes (must be &ge; {@code ServerProtocolIO.MIN_FALLBACK_PAYLOAD_BYTES})
562+
* @throws IllegalArgumentException if the value is below the minimum
556563
*/
557564
public void setMaxIpcPayloadBytes(int maxIpcPayloadBytes) {
558-
if (maxIpcPayloadBytes <= 0) {
565+
if (maxIpcPayloadBytes < ServerProtocolIO.MIN_FALLBACK_PAYLOAD_BYTES) {
559566
throw new IllegalArgumentException(
560-
"maxIpcPayloadBytes must be positive, got: " + maxIpcPayloadBytes);
567+
"maxIpcPayloadBytes must be at least " +
568+
ServerProtocolIO.MIN_FALLBACK_PAYLOAD_BYTES +
569+
" (minimum to carry a PAYLOAD_LIMIT_EXCEEDED response), got: " + maxIpcPayloadBytes);
561570
}
562571
this.maxIpcPayloadBytes = maxIpcPayloadBytes;
563572
}

tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/emitter/EmitDataImpl.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,17 @@ public void setParseContext(ParseContext parseContext) {
7777

7878
private static long estimateSizeInBytes(String id, List<Metadata> metadataList,
7979
String containerStackTrace) {
80-
long sz = 36 + id.length() * 2;
81-
sz += 36 + containerStackTrace.length() * 2;
80+
// Estimates Java heap cost (UTF-16: 2 bytes/char + object overhead).
81+
// Used by the DYNAMIC emit strategy to decide passback vs. direct-emit; it is not
82+
// used to enforce the IPC payload limit (that is handled by BoundedOutputStream in
83+
// ServerProtocolIO, which measures actual wire bytes during serialization).
84+
long sz = 36 + id.length() * 2L;
85+
sz += 36 + containerStackTrace.length() * 2L;
8286
for (Metadata m : metadataList) {
8387
for (String n : m.names()) {
84-
sz += 36 + n.length() * 2;
88+
sz += 36 + n.length() * 2L;
8589
for (String v : m.getValues(n)) {
86-
sz += 36 + v.length() * 2;
90+
sz += 36 + v.length() * 2L;
8791
}
8892
}
8993
}

tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/JsonPipesIpc.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.apache.tika.pipes.core.serialization;
1818

1919
import java.io.IOException;
20+
import java.io.OutputStream;
2021

2122
import com.fasterxml.jackson.core.StreamReadConstraints;
2223
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -72,6 +73,16 @@ public static byte[] toBytes(Object obj) throws IOException {
7273
return OBJECT_MAPPER.writeValueAsBytes(obj);
7374
}
7475

76+
/**
77+
* Serialize an object to Smile binary format, writing directly into {@code out}.
78+
* Any {@link IOException} thrown by {@code out} (e.g. from a size-capped stream)
79+
* propagates unchanged, letting callers distinguish payload-limit aborts from
80+
* genuine I/O errors.
81+
*/
82+
public static void toStream(Object obj, OutputStream out) throws IOException {
83+
OBJECT_MAPPER.writeValue(out, obj);
84+
}
85+
7586
/**
7687
* Deserialize Smile binary format bytes to an object.
7788
*/

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public ConnectionHandler(Socket socket, SharedServerResources resources, PipesCo
104104
this.resources = resources;
105105
this.pipesConfig = pipesConfig;
106106
this.heartbeatIntervalMillis = pipesConfig.getHeartbeatIntervalMillis();
107-
this.protocolIO = new ServerProtocolIO(input, output);
107+
this.protocolIO = new ServerProtocolIO(input, output, pipesConfig.getMaxIpcPayloadBytes());
108108
}
109109

110110
@Override
@@ -181,6 +181,20 @@ private void mainLoop() {
181181
LOG.error("handlerId={}: config error processing request", handlerId, e);
182182
handleCrash(PipesMessageType.UNSPECIFIED_CRASH, fetchEmitTuple.getId(), e);
183183
} catch (Throwable t) {
184+
if (t instanceof Error) {
185+
// OOM or other JVM-level error: don't trust the heap; exit
186+
// immediately. Everything before the exit is best-effort and
187+
// inside the try -- a secondary OOM in logging or writeCrash
188+
// must not escape and leave this shared JVM alive post-Error.
189+
try {
190+
LOG.error("handlerId={}: fatal JVM error; exiting", handlerId, t);
191+
protocolIO.writeCrash(PipesMessageType.OOM, t);
192+
} catch (Throwable ignored) {
193+
//swallow
194+
} finally {
195+
System.exit(PipesMessageType.OOM.getExitCode().orElse(18));
196+
}
197+
}
184198
// respond, or the client blocks until socket timeout and
185199
// restarts a healthy server
186200
LOG.error("handlerId={}: error processing request", handlerId, t);

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ public PipesServer(String pipesClientId, TikaLoader tikaLoader, PipesConfig pipe
189189
validateHeartbeatInterval(pipesConfig);
190190

191191
emitStrategy = pipesConfig.getEmitStrategy().getType();
192-
this.protocolIO = new ServerProtocolIO(input, output);
192+
this.protocolIO = new ServerProtocolIO(input, output, pipesConfig.getMaxIpcPayloadBytes());
193193
}
194194

195195

@@ -402,6 +402,12 @@ public void mainLoop() {
402402
try {
403403
loopUntilDone(fetchEmitTuple, mergedContext, executorCompletionService, intermediateResult, countDownLatch, parseTimeout);
404404
} catch (Throwable t) {
405+
if (t instanceof Error) {
406+
// OOM or other JVM-level error: exit rather than continue in a
407+
// possibly corrupt heap state.
408+
handleCrash(PipesMessageType.OOM, fetchEmitTuple.getId(), t);
409+
return; // handleCrash calls exit(); unreachable
410+
}
405411
LOG.error("Serious problem processing request", t);
406412
}
407413
break;

0 commit comments

Comments
 (0)