Skip to content

Commit cbfeb51

Browse files
authored
TIKA-4829 -- handle byte passing more cleanly (#3043)
1 parent 7b5f6a2 commit cbfeb51

24 files changed

Lines changed: 647 additions & 96 deletions

CHANGES.txt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
Release 4.1.0 - ???
1+
Release 4.1.0 - unreleased
2+
3+
* Pipes IPC: carry inline document bytes as a raw binary field beside the
4+
tuple in the request envelope -- never inside the tuple or its
5+
ParseContext -- and disable Smile's 7-bit binary encoding. Tuple JSON
6+
serialized by 4.0.0 with an "inline-bytes" parse-context entry no longer
7+
loads; it is rejected with a tailored message (TIKA-4829).
28

39
* Digesting embedded documents no longer buffers each embedded object to a
410
temp file. Zip entries are re-read from the parent archive on rewind, and
@@ -34,7 +40,6 @@ Release 4.1.0 - ???
3440
detection but also gains the attachments. Disable via
3541
"raw-tiff-parser": {"extractPreviews": false} (TIKA-4824).
3642

37-
3843
Release 4.0.0 - 8/18/2026
3944

4045
This section is the complete delta from 3.x. It includes everything first

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ IDs you configure yourself may contain only letters, digits, `.`, `_` and `-`, a
7070

7171
A host that already holds a document -- tika-server serving `/tika`, or an application calling `PipesForkParser` with an in-memory stream -- does not have to write it to disk for the forked worker to read. Content at or below xref:pipes/configuration.adoc#payload-limits[`maxInlineBytes`] travels inside the request and is served in the worker by the built-in `\_\_bytes` fetcher; larger content is written once to a file instead. A stream that is already backed by a file always keeps its file.
7272

73-
This is automatic and needs no configuration. `\_\_bytes` is not declarable in a config file and cannot be named by a request.
73+
This is automatic and needs no configuration. `\_\_bytes` is not declarable in a config file and cannot be named by a request. The payload itself has no request-suppliable form either: it travels beside the tuple in the host's parent-to-worker request envelope, never as a tuple field (`inlineBytes` in a request tuple is rejected). The IPC wire format is internal and same-version-only; parent and worker run from the same classpath by default, and a version-skewed worker classpath (a `-cp` override in `forkedJvmArgs`) is unsupported.
7474

7575
[#plugins]
7676
== Available Fetchers

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,11 @@ curl -X POST http://localhost:9998/async -H "Content-Type: application/json" \
156156

157157
A tuple's fields are `id`, `fetcher`, `fetchKey`, `emitter`, `emitKey`, and optionally
158158
`fetchRangeStart`, `fetchRangeEnd`, `metadata`, `parse-context` and `onParseException`. Any other
159-
field is a `400` — there is no silent tolerance for a typo. The `{"tuples":[...]}` envelope is
159+
field is a `400` — there is no silent tolerance for a typo. There is no field for content:
160+
`inlineBytes` draws a tailored `400`, because inline content is
161+
xref:pipes/fetchers.adoc#reserved-ids[how the server feeds its own workers], not something a
162+
tuple can carry. To parse content you already hold, PUT it to `/tika` or `/rmeta`, which inline
163+
it for you. The `{"tuples":[...]}` envelope is
160164
required on `/async`; a bare array is rejected.
161165

162166
=== Best practices

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
import org.apache.tika.pipes.core.protocol.PipesMessage;
5050
import org.apache.tika.pipes.core.protocol.PipesMessageType;
5151
import org.apache.tika.pipes.core.serialization.JsonPipesIpc;
52+
import org.apache.tika.pipes.core.serialization.PipesRequest;
5253
import org.apache.tika.pipes.core.server.IntermediateResult;
5354
import org.apache.tika.utils.ExceptionUtils;
5455
import org.apache.tika.utils.StringUtils;
@@ -240,6 +241,14 @@ public PipesResult process(FetchEmitTuple t) throws IOException, InterruptedExce
240241
serverManager.connectionAbandoned();
241242
closeConnection();
242243
throw e;
244+
} catch (PayloadLimitExceededException e) {
245+
// Only writeTask's pre-send check throws this here (waitForServer handles its
246+
// own); nothing was written, so the connection stays in sync -- keep it.
247+
LOG.warn("clientId={}: request too large for id={}: {}", pipesClientId, t.getId(),
248+
e.getMessage());
249+
return buildFatalResult(t.getId(), t.getEmitKey(),
250+
PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED,
251+
intermediateResult.get(), e.getMessage());
243252
} catch (Exception e) {
244253
LOG.error("exception waiting for server to complete task: {} ", t.getId(), e);
245254
closeConnection();
@@ -340,7 +349,14 @@ private void writeTask(FetchEmitTuple t) throws IOException {
340349
throw new IOException("connection closed");
341350
}
342351
LOG.debug("pipesClientId={}: sending NEW_REQUEST for id={}", pipesClientId, t.getId());
343-
byte[] bytes = JsonPipesIpc.toBytes(t);
352+
byte[] bytes = JsonPipesIpc.toBytes(PipesRequest.of(t));
353+
// Fail fast before sending: the server would refuse the frame anyway, but only by
354+
// dying or dropping the connection, misreported as a crash.
355+
if (bytes.length > maxIpcPayloadBytes) {
356+
throw new PayloadLimitExceededException("serialized request for id=" + t.getId()
357+
+ " is " + bytes.length + " bytes, over maxIpcPayloadBytes="
358+
+ maxIpcPayloadBytes + "; raise maxIpcPayloadBytes or shrink the request");
359+
}
344360
PipesMessage.newRequest(bytes).write(tuple.output);
345361
}
346362

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
@@ -601,7 +601,7 @@ public void setMaxInlineBytes(int maxInlineBytes) {
601601
* (the FINISHED payload) and the largest request the server will accept from the client
602602
* (the NEW_REQUEST payload). Lowering this value below the size of a typical
603603
* {@link org.apache.tika.pipes.api.FetchEmitTuple} will cause requests to be rejected
604-
* on the server side and reported as undiagnosable {@code UNSPECIFIED_CRASH} errors.
604+
* client-side before sending, reported as {@code PAYLOAD_LIMIT_EXCEEDED}.
605605
* <p>
606606
* The value must be at least {@link org.apache.tika.pipes.core.server.ServerProtocolIO#MIN_FALLBACK_PAYLOAD_BYTES}
607607
* so that the server can always write a {@code PAYLOAD_LIMIT_EXCEEDED} response

tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/fetcher/BytesFetcher.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
import org.apache.tika.utils.StringUtils;
3030

3131
/**
32-
* Serves the bytes a caller put in the {@link InlineBytes} parse-context entry, so a host that
32+
* Serves the bytes in the {@link InlineBytes} parse-context entry -- set by the caller
33+
* in-process, or planted by the server from the {@code PipesRequest} envelope -- so a host that
3334
* already holds the content does not have to spool it to disk purely to hand it across the
3435
* process boundary.
3536
* <p>

tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/fetcher/InlineBytes.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,19 @@
1919
import java.io.Serializable;
2020
import java.util.Arrays;
2121

22-
import org.apache.tika.annotation.TikaComponent;
23-
2422
/**
2523
* Document bytes carried in the {@code ParseContext} instead of fetched from a source, for
2624
* callers that already hold the content and would otherwise have to spool it to disk just to
2725
* hand it to the forked worker.
2826
* <p>
2927
* Read by {@link BytesFetcher}, which the tuple selects with fetcher id
30-
* {@link BytesFetcher#FETCHER_ID}. The IPC is Smile, so this rides as native binary rather than
31-
* base64; it counts against {@code maxIpcPayloadBytes} like any other part of the request.
28+
* {@link BytesFetcher#FETCHER_ID}. In-process only: deliberately not a registered component,
29+
* so no serialized form of it exists and serialization refuses loudly. On the IPC wire the
30+
* payload travels beside
31+
* the tuple in {@code PipesRequest} (which lifts it out on the parent and plants it back into
32+
* the worker's context on the child); it counts against {@code maxIpcPayloadBytes} like any
33+
* other part of the request. A request can supply it in no form at all.
3234
*/
33-
@TikaComponent(name = "inline-bytes", spi = false)
3435
public class InlineBytes implements Serializable {
3536

3637
private static final long serialVersionUID = 1L;

tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/protocol/PayloadLimitExceededException.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@
1919
import java.io.IOException;
2020

2121
/**
22-
* Thrown when an incoming IPC payload's declared length exceeds the configured limit
22+
* Thrown when an IPC payload exceeds the configured limit
2323
* (see {@link org.apache.tika.pipes.core.PipesConfig#getMaxIpcPayloadBytes()};
24-
* default {@link PipesMessage#MAX_PAYLOAD_BYTES}). The payload bytes were not consumed,
25-
* so the stream is desynchronized and the connection must be closed. With a shared server
26-
* the process keeps running (only this connection ends); with the default per-client forked
27-
* server the process may still exit on the failed write, and the client reconnects on the
28-
* next task.
24+
* default {@link PipesMessage#MAX_PAYLOAD_BYTES}). On the read side the payload bytes were
25+
* not consumed, so the stream is desynchronized and the connection must be closed: with a
26+
* shared server the process keeps running (only this connection ends); with the default
27+
* per-client forked server the process may still exit on the failed write, and the client
28+
* reconnects on the next task. On the send side ({@code PipesClient} pre-send check) nothing
29+
* was written and the connection stays usable.
2930
*/
3031
public class PayloadLimitExceededException extends IOException {
3132
public PayloadLimitExceededException(String message) {

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import java.util.Iterator;
3333
import java.util.Map;
3434
import java.util.Set;
35+
import java.util.TreeSet;
3536

3637
import com.fasterxml.jackson.core.JacksonException;
3738
import com.fasterxml.jackson.core.JsonParser;
@@ -49,6 +50,9 @@
4950

5051
public class FetchEmitTupleDeserializer extends JsonDeserializer<FetchEmitTuple> {
5152

53+
/** The parse-context name InlineBytes was registered under in 4.0.0. */
54+
static final String LEGACY_INLINE_BYTES_ENTRY = "inline-bytes";
55+
5256
private static final Set<String> KNOWN_KEYS = Set.of(
5357
ID, FETCHER, FETCH_KEY, EMITTER, EMIT_KEY, FETCH_RANGE_START, FETCH_RANGE_END,
5458
METADATA_KEY, PARSE_CONTEXT, ON_PARSE_EXCEPTION);
@@ -80,6 +84,20 @@ public static FetchEmitTupleDeserializer internal() {
8084
@Override
8185
public FetchEmitTuple deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
8286
JsonNode root = jsonParser.readValueAsTree();
87+
// Both checked before rejectUnknownKeys so they get tailored messages.
88+
if (root.has(PipesRequest.INLINE_BYTES)) {
89+
throw new IOException("'" + PipesRequest.INLINE_BYTES
90+
+ "' is not a FetchEmitTuple field; content travels outside the tuple, and"
91+
+ " only on the host's internal IPC. For tika-server, PUT content you"
92+
+ " already hold to /tika or /rmeta, which inline it for you.");
93+
}
94+
if (root.path(PARSE_CONTEXT).has(LEGACY_INLINE_BYTES_ENTRY)) {
95+
// 4.0.0 serialized this entry; the generic "check for a typo" would mislead upgraders.
96+
throw new IOException("'" + LEGACY_INLINE_BYTES_ENTRY + "' is no longer a serializable"
97+
+ " parse-context entry (4.0.0 wrote it as base64): content travels outside"
98+
+ " the tuple, and only on the host's internal IPC. For tika-server, PUT"
99+
+ " content you already hold to /tika or /rmeta, which inline it for you.");
100+
}
83101
rejectUnknownKeys(root);
84102

85103
String id = readVal(ID, root, null, true);
@@ -124,7 +142,7 @@ private static void rejectUnknownKeys(JsonNode root) throws IOException {
124142
String name = it.next();
125143
if (!KNOWN_KEYS.contains(name)) {
126144
throw new IOException("Unrecognized FetchEmitTuple field '" + name
127-
+ "'. Check for a typo; known fields are " + KNOWN_KEYS + ".");
145+
+ "'. Check for a typo; known fields are " + new TreeSet<>(KNOWN_KEYS) + ".");
128146
}
129147
}
130148
}

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
import com.fasterxml.jackson.databind.JsonSerializer;
2626
import com.fasterxml.jackson.databind.SerializerProvider;
2727

28+
import org.apache.tika.parser.ParseContext;
2829
import org.apache.tika.pipes.api.FetchEmitTuple;
30+
import org.apache.tika.pipes.core.fetcher.InlineBytes;
2931
import org.apache.tika.utils.StringUtils;
3032

3133
public class FetchEmitTupleSerializer extends JsonSerializer<FetchEmitTuple> {
@@ -57,8 +59,17 @@ public void serialize(FetchEmitTuple t, JsonGenerator jsonGenerator, SerializerP
5759
jsonGenerator.writeObjectField(METADATA_KEY, t.getMetadata());
5860
}
5961
jsonGenerator.writeStringField(ON_PARSE_EXCEPTION, t.getOnParseException().name().toLowerCase(Locale.US));
60-
if (!t.getParseContext().isEmpty()) {
61-
jsonGenerator.writeObjectField(PARSE_CONTEXT, t.getParseContext());
62+
ParseContext parseContext = t.getParseContext();
63+
// Tailored: ParseContextSerializer's generic refusal suggests registering the
64+
// component -- for InlineBytes, exactly the forbidden fix.
65+
if (parseContext.get(InlineBytes.class) != null) {
66+
throw new IOException("A FetchEmitTuple whose ParseContext holds InlineBytes has no"
67+
+ " serialized form: inline content is in-process only and, on the pipes IPC,"
68+
+ " travels beside the tuple in the request envelope. Remove the InlineBytes"
69+
+ " entry before serializing.");
70+
}
71+
if (!parseContext.isEmpty()) {
72+
jsonGenerator.writeObjectField(PARSE_CONTEXT, parseContext);
6273
}
6374
jsonGenerator.writeEndObject();
6475
}

0 commit comments

Comments
 (0)