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 7eae3ba3ba Add performance findings and options for pipes (#3041)
7eae3ba3ba is described below
commit 7eae3ba3bafa1c5a2ce8cca51a85a8b411d9ecd8
Author: Tim Allison <[email protected]>
AuthorDate: Thu Aug 20 14:18:13 2026 -0400
Add performance findings and options for pipes (#3041)
-- prep for 4.0.0 release
---
docs/modules/ROOT/nav.adoc | 1 +
.../migration-to-4x/migrating-tika-server-4x.adoc | 6 +
docs/modules/ROOT/pages/pipes/performance.adoc | 263 +++++++++++++++++++++
.../ROOT/pages/pipes/shared-server-mode.adoc | 13 +
docs/modules/ROOT/pages/using-tika/cli/index.adoc | 3 +
5 files changed, 286 insertions(+)
diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc
index b1bb44ef77..fe2b5105f7 100644
--- a/docs/modules/ROOT/nav.adoc
+++ b/docs/modules/ROOT/nav.adoc
@@ -34,6 +34,7 @@
** xref:pipes/timeouts.adoc[Timeouts]
** xref:pipes/cpu-sizing.adoc[Forked-JVM CPU and Heap Sizing]
** xref:pipes/shared-server-mode.adoc[Shared Server Mode]
+** xref:pipes/performance.adoc[Performance and Isolation Trade-offs]
** xref:pipes/troubleshooting.adoc[Troubleshooting]
** xref:pipes/plugins/index.adoc[Plugins]
*** xref:pipes/plugins/writing-a-plugin.adoc[Writing a Pipes Plugin]
diff --git
a/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc
b/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc
index 1f11111719..38ebe8a10d 100644
--- a/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc
+++ b/docs/modules/ROOT/pages/migration-to-4x/migrating-tika-server-4x.adoc
@@ -523,6 +523,12 @@ All parsing now occurs in isolated child processes,
providing:
* Memory isolation (OOM in parser doesn't crash server)
* Configurable timeouts at the pipes level
+This isolation carries a throughput and memory cost relative to a single in-JVM
+parser, and the size of that cost depends on your document mix and the pool
+configuration (per-client versus shared-server, `numClients`, per-fork heap).
+See xref:pipes/performance.adoc[Performance and Isolation Trade-offs] for the
+comparison and tuning guidance.
+
[#backpressure]
=== Backpressure: `429` Separates "Busy" From "Broken"
diff --git a/docs/modules/ROOT/pages/pipes/performance.adoc
b/docs/modules/ROOT/pages/pipes/performance.adoc
new file mode 100644
index 0000000000..612d685c12
--- /dev/null
+++ b/docs/modules/ROOT/pages/pipes/performance.adoc
@@ -0,0 +1,263 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+= Performance and Isolation Trade-offs
+
+In Tika 4.x, `tika-server`'s classic endpoints (`/tika`, `/rmeta`, `/meta`,
+`/detect`, `/unpack`) parse through Tika Pipes by default: the HTTP front-end
+hands each document to a pool of forked worker JVMs rather than parsing in the
+server process. This buys crash/OOM isolation at the cost of a per-request
+overhead — spooling large payloads to a temp file, a socket round-trip, and
+serializing the result back. This page describes that trade-off and how to tune
+for it.
+
+[NOTE]
+====
+Closing the throughput gap of the default isolated mode — *without* giving up
the
+crash/OOM isolation it provides — is an area of active work. Treat the figures
on
+this page as a snapshot of the 4.0.0 release, not a fixed ceiling: expect the
+isolated-mode gap to narrow in future releases. Note also that this gap is
+specific to the *upload* endpoints on small documents — for file-system inputs
+and outputs the fetch/emit endpoints already match or beat 3.x while staying
+fully isolated (see <<endpoint-choice>>).
+====
+
+== Deployment shapes
+
+[cols="1,3,2"]
+|===
+|Shape |Description |Parsing JVMs
+
+|In-process (legacy)
+|Tika 3.x with `--noFork`. The server parses in its own JVM. No isolation, no
+recovery. Not recommended.
+|1 (the server itself)
+
+|Single forked child
+|Tika 3.x *default*. A thin watchdog parent forks one child that binds the port
+and does all parsing; the watchdog restarts it on crash/OOM/timeout.
+|1 (the child)
+
+|Pipes per-client
+|Tika 4.x *default*. The HTTP front-end forks `numClients` worker JVMs; each
+handles one request at a time.
+|`numClients` (e.g. 4)
+
+|Pipes shared-server
+|Tika 4.x opt-in (`useSharedServer=true`; see
+xref:pipes/shared-server-mode.adoc[Shared Server Mode]). The front-end forks a
+single worker JVM with a `numClients`-sized thread pool.
+|1 (shared worker)
+|===
+
+The single-forked-child (3.x) and shared-server (4.x) shapes are close cousins:
+one parsing JVM serving all concurrency, with the front-end/watchdog restarting
+it on failure. The practical 4.x default choice is between **per-client**
+(strongest isolation) and **shared-server** (highest throughput).
+
+== Throughput
+
+The pipes per-request overhead — temp-spool of large payloads, socket IPC, and
+result serialization — is roughly *fixed per request*. It therefore dominates
+when parse time is small (many tiny documents) and amortizes away as documents
+get larger and parsing dominates.
+
+Relative throughput at matched concurrency (requesting threads = worker count),
+normalized to a single in-JVM parser of the same total heap (= 1.00; higher is
+faster). These are representative figures from one benchmark (16-core host, JDK
+17, loopback HTTP, plain-text extraction) and are meant to show the *shape* of
+the trade-off, not to be quoted as absolutes:
+
+[cols="2,1,1,1"]
+|===
+|Corpus |Single in-JVM (8g) |Shared-server (1×8g) |Per-client (4×2g)
+
+|Many small files (~50 KB HTML) |1.00 |~0.60 |~0.47
+|Mixed (~350 KB avg) |1.00 |~0.85 |~0.65
+|Large (multi-MB, up to ~50 MB) |1.00 |~0.95 |~0.82
+|===
+
+Two things to note:
+
+* The gap is widest on small files (per-request overhead is the whole cost) and
+ nearly closes on large files (parse time dominates).
+* **Shared-server recovers most of the pipes overhead relative to per-client**
—
+ one warm JVM with shared JIT and one garbage collector outperforms several
+ smaller, independently-warming worker heaps.
+
+[#endpoint-choice]
+== Endpoint choice: uploading bytes vs fetch-and-emit
+
+How a document reaches the parser matters as much as the parsing mode. The
+classic endpoints (`/tika`, `/rmeta`, `/meta`) receive the document *in the
HTTP
+request body* and return the extract *in the response*, so every request pays
to
+move the bytes in and the result back out — and in 4.x that now crosses the
+process boundary to a forked worker. The pipes endpoints (`/pipes`, `/async`)
+instead take only a small fetch/emit *tuple*: the worker reads the document
+straight from the configured xref:pipes/fetchers.adoc[fetcher] — a file system,
+Amazon S3, Google Cloud Storage, Azure Blob Storage, and so on — and the
+configured xref:pipes/emitters.adoc[emitter] writes the result straight to its
+destination, which need not be a file at all: an object store, a search index
+(OpenSearch, Solr, Elasticsearch), a database, a queue. The bytes never travel
+over HTTP and the result is never passed back through the front-end.
+
+Whenever a fetcher can reach your inputs and an emitter your destination, the
+fetch/emit endpoints skip the HTTP body transfer and the result passback — a
+saving that holds for any fetcher and emitter. What that is worth in
*throughput*
+depends on the store, and the only combination measured here is **local file
+system on both ends**. Those figures, relative to a 3.x single in-JVM parser
+(= 1.00; higher is faster; one 16-core host, plain-text recursive metadata,
+concurrency = worker count, per-client isolation):
+
+[cols="2,1,1"]
+|===
+|Document size |4.x sync `/rmeta` (HTTP upload) |4.x `/pipes` (fetch/emit)
+
+|Small (~50 KB) |0.50 |0.80
+|Medium (~350 KB) |0.73 |1.23
+|Large (multi-MB) |0.81 |1.14
+|===
+
+Two things to read from it:
+
+* The classic upload endpoints *are* slower than 3.x's in-JVM parsing — by ~2x
on
+ tiny documents, shrinking toward ~20% as documents grow and parse time
+ dominates. That is the crash-isolation cost, and it lands on the per-request
+ HTTP path.
+* The fetch/emit path — still *fully isolated* (per-client: one forked worker
per
+ in-flight document) — *matches or beats* a 3.x in-JVM parser on realistic and
+ large documents, because it drops the HTTP body transfer and the result
+ passback. Only on very small documents does it trail. (The figures are for
+ local file-system fetch and emit; a remote store adds its own latency and
+ bandwidth, but the architecture — fetch, parse in an isolated worker, emit —
+ is unchanged.)
+
+So a *file-system* fetch-and-emit workload need not choose between 3.x
throughput
+and 4.x isolation: measured file system to file system, `/pipes` (and `/async`)
+delivered both. With other fetchers and emitters you keep the isolation and the
+skipped HTTP-body/passback, and the extract can land straight in a search index
+or database instead of round-tripping back through your client — but the
+throughput then also rides on that store's own latency and bandwidth, which we
+have not measured, so treat those cases as architecturally similar rather than
+numerically equal. The upload endpoints remain the convenient choice for
+interactive, single-document requests where the bytes are already in hand and
+isolation — not raw throughput — is what you are buying.
+
+== Latency
+
+Pipes adds a fixed floor of roughly tens of milliseconds per request from the
+IPC round-trip, visible at the median on fast parses.
+
+For the *tail*, isolating the parse JVM from the HTTP front-end (both pipes
+modes) keeps a slow or pathological document off the request-accept path. In
+per-client mode a single slow document occupies only one of `numClients`
+workers; in shared-server and single-child modes it occupies one of the shared
+thread pool's slots. In practice shared-server can show the *best* worst-case
+latency of the shapes here, because it combines a large single heap (fewer,
+shorter GC stalls than several small heaps) with a front-end that is never
+blocked by parsing.
+
+The output format also matters: full XHTML, Markdown, plain text, and recursive
+metadata JSON impose different serialization costs on the same parse. Compare
+like with like when benchmarking.
+
+== Memory
+
+Per-client mode runs `numClients` heaps; size each for the worst-case *single*
+document. Shared-server and single-JVM modes run one heap; size it for the
+worst-case *concurrent* load (see
+xref:pipes/shared-server-mode.adoc#_sizing_guidance[Shared-server sizing]).
Per-client
+therefore uses more total resident memory but bounds per-document usage: a
+memory-hungry document can only exhaust its own worker's heap, not the pool's.
+For the per-fork `-Xmx` and CPU rules of thumb, see
+xref:pipes/cpu-sizing.adoc[Forked-JVM CPU and Heap Sizing].
+
+== Isolation and recovery
+
+Every shape below *except* 3.x `--noFork` recovers automatically from a
+crash, `OutOfMemoryError`, or timeout. They differ in how many in-flight
+requests a single failure takes down, and whether the HTTP endpoint stays up:
+
+[cols="2,2,2,2"]
+|===
+|Shape |Blast radius |HTTP front-end |Recovery
+
+|In-process (`--noFork`) |All in-flight |Dies |None — manual restart
+|Single forked child (3.x default) |All in-flight (shared child) |Brief outage
while the child restarts (the child owns the port) |Auto — watchdog restarts
child
+|Shared-server (4.x) |All in-flight (shared worker) |Stays up (separate
front-end) |Auto — front-end respawns worker
+|Per-client (4.x default) |One request (1 of `numClients`) |Stays up |Auto —
only that worker respawns
+|===
+
+3.x already provides process isolation in its default configuration: the forked
+child survives a parser crash, OOM, or timeout because the watchdog restarts
it.
+Only the legacy `--noFork` mode parses in the server process itself and has no
+recovery. So the 4.x change is a *finer* granularity of isolation, not
isolation
+where there was none — per-client mode narrows the blast radius from "all
+in-flight" to "one request," and both pipes modes keep the HTTP front-end
serving
+while a worker restarts.
+
+== Choosing a shape
+
+* **Per-client (default)** — hostile or heterogeneous inputs, where one bad
+ document must not disturb the others. Strongest isolation; highest memory;
+ lowest raw throughput.
+* **Shared-server** — well-behaved inputs where you want throughput close to a
+ single in-JVM parser and a crash-resilient front-end, and can accept that one
+ failure drops all in-flight requests. See
+ xref:pipes/shared-server-mode.adoc[Shared Server Mode].
+* Tune `numClients` and per-fork heap with
+ xref:pipes/cpu-sizing.adoc[Forked-JVM CPU and Heap Sizing]; configure
per-parse
+ limits with xref:pipes/timeouts.adoc[Timeouts].
+
+== Benchmarking your own workload
+
+The numbers above are illustrative. Throughput depends on your document mix,
+document sizes, requested output format, concurrency, host CPU/heap, and disk
+speed (large payloads spool to a temp directory). Measure with *your* corpus:
+
+* Fix concurrency equal to the worker count so the comparison is apples to
+ apples.
+* Exclude a warm-up phase — forked workers pay a one-time fork + JIT cost on
+ their first requests.
+* Hold the output format constant across the versions or modes you compare.
+* Watch peak RSS across the whole process tree (front-end plus workers), not
+ just one process.
+
+== Appendix: approaches considered and set aside
+
+Levers that were tried against the isolated-mode throughput gap and do *not*
+close it, recorded here so they need not be re-litigated:
+
+* **Class-data sharing (CDS / AppCDS).** A shared archive measurably speeds
+ worker *start-up* (class loading is a one-time cost), but class loading is
not a
+ steady-state cost, so parsing throughput is unchanged. CDS is still worth
having
+ for faster worker cold-start and restart — a resilience/latency benefit that
is
+ compatible with the hard-kill lifecycle, since the archive is generated
offline
+ and mapped read-only (a worker can be force-killed at any instant). It is
not,
+ however, a throughput lever.
+* **Uncapping the per-fork CPU view.** Raising or removing the auto-injected
+ `-XX:ActiveProcessorCount` slice makes throughput *worse*: N forks each
sizing
+ their GC and JIT thread pools to the full host core count oversubscribes the
+ cores. The slice (see xref:pipes/cpu-sizing.adoc[Forked-JVM CPU and Heap
Sizing])
+ is doing its job.
+* **Swapping the garbage collector.** ParallelGC helped tiny documents
marginally
+ and hurt larger ones — no reliable win over the default across a mixed
corpus.
+
+What is left is structural: the fixed per-request IPC + temp-spool + result
+serialization cost, and running several CPU-partitioned JVMs instead of one.
The
+productive directions are shrinking that per-request cost (RAM-disk temp
directory,
+keeping more payloads inline, leaner serialization) and fork-pool sizing — not
a
+single JVM flag.
diff --git a/docs/modules/ROOT/pages/pipes/shared-server-mode.adoc
b/docs/modules/ROOT/pages/pipes/shared-server-mode.adoc
index e653f90ff7..35d293e757 100644
--- a/docs/modules/ROOT/pages/pipes/shared-server-mode.adoc
+++ b/docs/modules/ROOT/pages/pipes/shared-server-mode.adoc
@@ -43,6 +43,19 @@ Memory: 4 JVMs Memory: 1 JVM
Isolation: Per-request Isolation: None (shared fate)
----
+== Performance
+
+Shared mode is not only a memory option. Because all requests are served by one
+warm JVM — a single JIT profile and a single garbage collector — it typically
+has *higher throughput* than per-client mode at the same concurrency, and can
+show lower tail latency (one large heap incurs fewer, shorter GC stalls than
+several small worker heaps). In one benchmark it came within roughly 5–15% of a
+single in-JVM parser's throughput on mixed and large documents, where
per-client
+was ~20–35% slower. This throughput edge is the upside you weigh against the
loss
+of per-request isolation described below. See
+xref:pipes/performance.adoc[Performance and Isolation Trade-offs] for the full
+comparison.
+
== Limitations and Risks
=== Shared fate
diff --git a/docs/modules/ROOT/pages/using-tika/cli/index.adoc
b/docs/modules/ROOT/pages/using-tika/cli/index.adoc
index 121a1e3e12..f4979f4181 100644
--- a/docs/modules/ROOT/pages/using-tika/cli/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/cli/index.adoc
@@ -284,6 +284,9 @@ java -jar tika-app.jar -i /path/to/input -o /path/to/output
That parses every file under the input directory and writes JSON metadata
(RMETA format) to the
output directory.
+The throughput, memory, and isolation characteristics of this forked-JVM
pipeline — and how to
+size it — are described in xref:pipes/performance.adoc[Performance and
Isolation Trade-offs].
+
[#_how_pipes_mode_is_activated]
=== How Pipes mode is activated