1616//
1717= Performance and Isolation Trade-offs
1818
19- In Tika 4.x, `tika-server`'s classic endpoints (`/tika`, `/rmeta`, `/meta`,
20- `/detect`, `/unpack`) parse through Tika Pipes by default: the HTTP front-end
21- hands each document to a pool of forked worker JVMs rather than parsing in the
22- server process. This buys crash/OOM isolation at the cost of a per-request
23- overhead — spooling large payloads to a temp file, a socket round-trip, and
24- serializing the result back. This page describes that trade-off and how to tune
25- for it.
19+ Tika 4.x parses through Tika Pipes: each document is handled by a pool of forked
20+ worker JVMs rather than in the calling process. That architecture pays off in two
21+ ways — crash/OOM isolation (a bad document can't take down the server), and, for
22+ `tika-server`, a reliable **backpressure signal**: because parsing runs in a
23+ managed worker pool, the server can see when it is saturated and push back,
24+ rather than accepting unbounded work until it topples the way a single in-process
25+ parser could. That signal simply did not exist before pipes, and it is one of the
26+ strongest reasons to run 4.x.
27+
28+ This page covers the pipes deployment shapes, their isolation and recovery
29+ behaviour, and **file-system-to-file-system batch throughput** — a worker fetches
30+ each document from a file system and emits the extract to a file system. A
31+ dedicated `tika-server` performance analysis (the HTTP upload endpoints `/tika`,
32+ `/rmeta`, and so on, and the backpressure behaviour above) is planned as a
33+ companion to this page.
2634
2735[NOTE]
2836====
29- The tables in the first half of this page were measured on the **4.0.0
30- release** and are kept as that snapshot. After 4.0.0 shipped we traced the
31- batch slowdown against 3.x to a specific cause — temp-file volume, not the
32- pipes architecture — and fixed it for **4.1.0**. The findings, the fix, and a
33- worked configuration from the box where it was diagnosed are in
34- <<found-in-400>> and <<corpora-case-study>>. The upload-endpoint gap on tiny
35- documents (<<endpoint-choice>>) is a separate, smaller effect and still
36- applies.
37+ The batch measurements (<<found-in-400>>) are on **4.1.0-SNAPSHOT**, which
38+ includes the temp-file spill improvements described there.
3739====
3840
3941== Deployment shapes
@@ -69,112 +71,6 @@ one parsing JVM serving all concurrency, with the front-end/watchdog restarting
6971it on failure. The practical 4.x default choice is between **per-client**
7072(strongest isolation) and **shared-server** (highest throughput).
7173
72- == Throughput (4.0.0 measurements)
73-
74- The pipes per-request overhead — temp-spool of large payloads, socket IPC, and
75- result serialization — is roughly *fixed per request*. It therefore dominates
76- when parse time is small (many tiny documents) and amortizes away as documents
77- get larger and parsing dominates.
78-
79- Relative throughput at matched concurrency (requesting threads = worker count),
80- normalized to a single in-JVM parser of the same total heap (= 1.00; higher is
81- faster). These are representative figures from one benchmark (16-core host, JDK
82- 17, loopback HTTP, plain-text extraction) and are meant to show the *shape* of
83- the trade-off, not to be quoted as absolutes:
84-
85- [cols="2,1,1,1"]
86- |===
87- |Corpus |Single in-JVM (8g) |Shared-server (1×8g) |Per-client (4×2g)
88-
89- |Many small files (~50 KB HTML) |1.00 |~0.60 |~0.47
90- |Mixed (~350 KB avg) |1.00 |~0.85 |~0.65
91- |Large (multi-MB, up to ~50 MB) |1.00 |~0.95 |~0.82
92- |===
93-
94- Two things to note:
95-
96- * The gap is widest on small files (per-request overhead is the whole cost) and
97- nearly closes on large files (parse time dominates).
98- * **Shared-server recovers most of the pipes overhead relative to per-client** —
99- one warm JVM with shared JIT and one garbage collector outperforms several
100- smaller, independently-warming worker heaps.
101-
102- [#endpoint-choice]
103- == Endpoint choice: uploading bytes vs fetch-and-emit (4.0.0 measurements)
104-
105- How a document reaches the parser matters as much as the parsing mode. The
106- classic endpoints (`/tika`, `/rmeta`, `/meta`) receive the document *in the HTTP
107- request body* and return the extract *in the response*, so every request pays to
108- move the bytes in and the result back out — and in 4.x that now crosses the
109- process boundary to a forked worker. The pipes endpoints (`/pipes`, `/async`)
110- instead take only a small fetch/emit *tuple*: the worker reads the document
111- straight from the configured xref:pipes/fetchers.adoc[fetcher] — a file system,
112- Amazon S3, Google Cloud Storage, Azure Blob Storage, and so on — and the
113- configured xref:pipes/emitters.adoc[emitter] writes the result straight to its
114- destination, which need not be a file at all: an object store, a search index
115- (OpenSearch, Solr, Elasticsearch), a database, a queue. The bytes never travel
116- over HTTP and the result is never passed back through the front-end.
117-
118- Whenever a fetcher can reach your inputs and an emitter your destination, the
119- fetch/emit endpoints skip the HTTP body transfer and the result passback — a
120- saving that holds for any fetcher and emitter. What that is worth in *throughput*
121- depends on the store, and the only combination measured here is **local file
122- system on both ends**. Those figures, relative to a 3.x single in-JVM parser
123- (= 1.00; higher is faster; one 16-core host, plain-text recursive metadata,
124- concurrency = worker count, per-client isolation):
125-
126- [cols="2,1,1"]
127- |===
128- |Document size |4.x sync `/rmeta` (HTTP upload) |4.x `/pipes` (fetch/emit)
129-
130- |Small (~50 KB) |0.50 |0.80
131- |Medium (~350 KB) |0.73 |1.23
132- |Large (multi-MB) |0.81 |1.14
133- |===
134-
135- Two things to read from it:
136-
137- * The classic upload endpoints *are* slower than 3.x's in-JVM parsing — by ~2x on
138- tiny documents, shrinking toward ~20% as documents grow and parse time
139- dominates. That is the crash-isolation cost, and it lands on the per-request
140- HTTP path.
141- * The fetch/emit path — still *fully isolated* (per-client: one forked worker per
142- in-flight document) — *matches or beats* a 3.x in-JVM parser on realistic and
143- large documents, because it drops the HTTP body transfer and the result
144- passback. Only on very small documents does it trail. (The figures are for
145- local file-system fetch and emit; a remote store adds its own latency and
146- bandwidth, but the architecture — fetch, parse in an isolated worker, emit —
147- is unchanged.)
148-
149- So a *file-system* fetch-and-emit workload need not choose between 3.x throughput
150- and 4.x isolation: measured file system to file system, `/pipes` (and `/async`)
151- delivered both. With other fetchers and emitters you keep the isolation and the
152- skipped HTTP-body/passback, and the extract can land straight in a search index
153- or database instead of round-tripping back through your client — but the
154- throughput then also rides on that store's own latency and bandwidth, which we
155- have not measured, so treat those cases as architecturally similar rather than
156- numerically equal. The upload endpoints remain the convenient choice for
157- interactive, single-document requests where the bytes are already in hand and
158- isolation — not raw throughput — is what you are buying.
159-
160- == Latency
161-
162- Pipes adds a fixed floor of roughly tens of milliseconds per request from the
163- IPC round-trip, visible at the median on fast parses.
164-
165- For the *tail*, isolating the parse JVM from the HTTP front-end (both pipes
166- modes) keeps a slow or pathological document off the request-accept path. In
167- per-client mode a single slow document occupies only one of `numClients`
168- workers; in shared-server and single-child modes it occupies one of the shared
169- thread pool's slots. In practice shared-server can show the *best* worst-case
170- latency of the shapes here, because it combines a large single heap (fewer,
171- shorter GC stalls than several small heaps) with a front-end that is never
172- blocked by parsing.
173-
174- The output format also matters: full XHTML, Markdown, plain text, and recursive
175- metadata JSON impose different serialization costs on the same parse. Compare
176- like with like when benchmarking.
177-
17874== Memory
17975
18076Per-client mode runs `numClients` heaps; size each for the worst-case *single*
@@ -224,13 +120,15 @@ while a worker restarts.
224120 limits with xref:pipes/timeouts.adoc[Timeouts].
225121
226122[#found-in-400]
227- == What we found in 4.0.0, and what 4. 1.0 changes
123+ == Restoring batch throughput in 4.1.0
228124
229- Our own regression testing runs `tika-app` in batch mode over a 1.2-million-file
230- corpus (file system in, file system out) on a box with spinning disks. That run
231- took about 4 hours on Tika 3.x and about 7.5 hours on 4.0.0. The investigation
232- that followed is worth summarizing, because the cause was not where the
233- architecture suggested it would be.
125+ 4.1.0 brings file-system batch throughput back in line with 3.x while keeping the
126+ isolation and backpressure gains above — here is how it got there. Our own
127+ regression testing runs `tika-app` in batch mode over a 1.2-million-file corpus
128+ (file system in, file system out) on a box with spinning disks: about 4 hours on
129+ 3.x, and about 7.5 hours on 4.0.0. The cause turned out not to be where the
130+ architecture suggested, which made it both surprising to find and clean to
131+ improve.
234132
235133=== The cause: temp-file volume
236134
@@ -265,7 +163,7 @@ Each of these was measured and ruled out, so they need not be re-chased:
265163* 4.x extracting more embedded objects (it does, about 3% more) — negligible
266164 cost.
267165
268- === The fix (4.1.0, unreleased at the time of writing )
166+ === The improvement (4.1.0-SNAPSHOT )
269167
270168* TIKA-4828/TIKA-4829: embedded zip entries are re-read from the archive on
271169 rewind instead of being copied, and a process-wide `CacheMemoryBudget`
@@ -276,28 +174,63 @@ Each of these was measured and ruled out, so they need not be re-chased:
276174 to disk; they rewind or read through a seekable channel, within the same
277175 budget, and fall back to a file only past it.
278176
279- Measured on the diagnosis box (20,000 randomly sampled files of the corpus,
280- page cache evicted before each run, extracts written to the corpus disk):
177+ We then measured it as a controlled study on the same box, one variable at a
178+ time: 100,000 documents randomly sampled from the corpus (fixed, md5-pinned
179+ list reused across every run), page cache evicted cold before each run,
180+ plain-text extraction and SHA-256 digest held constant, extracts written to the
181+ corpus disk. Every version (3.x, 4.0.0, 4.1.0) was run in each 4.x process shape
182+ so version and shape vary independently. Concurrency was fixed at seven
183+ workers (the throughput sweet spot on this host — see <<corpora-case-study>>)
184+ and heap at 4 GB per worker thread everywhere. Each cell was run at least twice;
185+ a third rep was added automatically wherever the two disagreed by more than 10%.
186+ Run-to-run agreement was within ±6% for every cell but one. Medians, measured on
187+ 4.1.0-SNAPSHOT (August 2026):
281188
282189[cols="3,1,1"]
283190|===
284- |Build and configuration |Temp written |Wall
191+ |Version and shape |Wall (median) |Temp written
285192
286- |Tika 3.x, 10 consumer threads (three runs) |0.33 GB |245 / 287 / 328 s
287- |4.0 .0, per-client, 8 workers (the 7.5 h shape) |10.5 GB |500 s
288- |4.1.0 with TIKA-4828/29 only, corrected config |2.8 GB |341 s
289- |4.1 .0, shared server, 10 threads |0.26 GB |277 s
290- |4.1 .0, per-client, 7 workers |1.1 GB |287 s
193+ |Tika 3.x |25.2 min |3.3 GB
194+ |4.1 .0, shared server |27.2 min |1.4 GB
195+ |** 4.1.0, per-client (default)** |**29.0 min** |8.2 GB
196+ |4.0 .0, shared server |41.1 min |57 GB
197+ |4.0 .0, per-client |42.0 min |57 GB
291198|===
292199
293- The like-for-like pair is 2.8 GB → 1.1 GB (both per-client); the 0.26 GB row
294- also changes shape to shared-server, which pools the cache budget across
295- threads.
296-
297- 4.1.0 writes less temp than 3.x did, and matches or beats 3.x throughput while
298- keeping process isolation. These are subset measurements on one host; we have
299- not re-timed the full run, and remote emitters (Solr, OpenSearch, S3) were not
300- measured.
200+ The like-for-like pair is 4.0.0 → 4.1.0 per-client (same seven workers, same
201+ 4 GB heap): **1.45× faster**, with temp falling from 57 GB to 8 GB. Shared
202+ server recovers 1.51×. On this box the 100k subset reproduces the full-run
203+ story — 4.0.0 was about 1.65× slower than 3.x, matching the 7.5 h / 4.5 h ratio.
204+
205+ **4.1.0 lands within about 15% of 3.x** in its default per-client configuration
206+ (median-to-median; range +11% to +20%, since 3.x is the fastest cell and its
207+ ±6% run variance drives the ratio). That is a modest price, and it buys real
208+ isolation: where 3.x parses every document in one JVM — so a single fatal
209+ document takes down the whole run — 4.x parses each in its own worker, and
210+ shared-server mode closes the gap further still (+8%) when you want it.
211+
212+ That +15% has two parts, separated by a single-client run (one worker, otherwise
213+ identical) where the per-client tax over 3.x drops to about **+7%**:
214+
215+ * **~+7% per-request boundary** — crossing the process boundary to a worker and
216+ serializing the result back. Present in both shapes at every concurrency
217+ (shared server's tax is a flat ~8% at one thread or seven), and inherent to
218+ isolation.
219+ * **Up to ~+8% more, only in per-client** — at seven workers, per-client (seven
220+ JVMs) and shared server (one JVM) run under the same driver and disk load and
221+ differ only in JVM count, so the ~7-point gap between them is the cost of
222+ several worker JVMs on one box: independent GCs and JIT caches, and contention
223+ for CPU, memory bandwidth, and last-level cache. Whether GC/heap tuning, CPU
224+ pinning, or fewer/fatter workers reduce it is under investigation; how it
225+ scales between one and seven workers was not measured.
226+
227+ (At one client per-client edges out shared server — shared server's single-JVM
228+ advantage only pays off once several worker JVMs would otherwise contend.)
229+
230+ Caveats: 4.1.0-SNAPSHOT, one 100k subset on one host; the full 1.2M run was not
231+ re-timed and remote emitters were not measured. The win is storage-dependent
232+ (<<corpora-case-study>>, <<reading-your-own>>), and the isolation split is still
233+ under investigation — read these as a snapshot, not a final characterization.
301234
302235[#corpora-case-study]
303236== A worked configuration: the regression-test box
@@ -312,7 +245,8 @@ every run is effectively cold-cache. The configuration we settled on:
312245 when that slice is at least 2. On 16 logical cores, 8 workers give 1.75 and
313246 the cap is *skipped* — eight JVMs each sizing GC and JIT for 16 cores, which
314247 is what the 7.5 h run did. Seven workers get 2 cores each and the cap
315- applies. Per-client cost about 4% versus shared-server here (287 s vs 277 s)
248+ applies. Per-client cost about 7% versus shared-server here (29.0 min vs
249+ 27.2 min in the controlled study above)
316250 and dropped no files, where the shared worker loses the in-flight documents
317251 of every other client when one document crashes it.
318252* **`-Xmx4g` per fork.** The cache budget clamps to a quarter of the fork heap,
@@ -323,6 +257,7 @@ every run is effectively cold-cache. The configuration we settled on:
323257* Digest MD5 (SHA-256 measured within noise), default emit strategy, temp
324258 directory left on disk.
325259
260+ [#reading-your-own]
326261=== Reading your own deployment
327262
328263Three questions decided the result above, and they are cheap to answer for any
@@ -391,7 +326,7 @@ Once a site is found, lock it with a test rather than re-running the
391326diagnostic: wrap the parser's `TikaInputStream` so any `getFile()`/`getPath()`
392327call is recorded, and assert none happened. A watched temp directory is not
393328enough — not every `TemporaryResources` on the path is bound to it — and a
394- test that passes with the fix reverted is not a test.
329+ test that passes with the improvement reverted is not a test.
395330
396331== Appendix: approaches considered and set aside
397332
@@ -425,8 +360,3 @@ close it, recorded here so they need not be re-litigated:
425360 count against a container's memory limit and get the pod evicted. A slow run
426361 is recoverable; that is not. 4.1.0 removes the temp writes instead of hiding
427362 them.
428-
429- What remains structural on the *upload* endpoints is the fixed per-request IPC +
430- result-serialization cost and running several CPU-partitioned JVMs instead of one;
431- the productive directions there are keeping more payloads inline, leaner
432- serialization, and fork-pool sizing — not a single JVM flag.
0 commit comments