Skip to content

Commit 5c1a3e7

Browse files
authored
Merge branch 'main' into TIKA-4862-pdfbox-image-quality
2 parents 7290a9e + f865899 commit 5c1a3e7

5 files changed

Lines changed: 91 additions & 159 deletions

File tree

CHANGES.txt

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
Release 4.1.0 - unreleased
22

3-
* PDF page rendering writes compressed PNGs: PDFBoxRenderer gained an
4-
imageQuality setting (new default 0.5) and OcrConfig.imageQuality's
5-
default moved from 1.0 to 0.5. ImageIO's PNG "quality" is an inverted
6-
effort knob - 1.0 wrote uncompressed files (~3.9 MB per 200-dpi gray
7-
page) and the renderer's previous unset value spent ~10x the encode
8-
time of 0.5 for a few percent smaller output; 0.5 is both fast and
9-
small, and PNG stays lossless. Set ocr.imageQuality to restore old
10-
behavior (TIKA-4862).
3+
* Allow image compression settings in PDFBox-based renderer (TIKA-4862).
4+
5+
* The tika-server full and tika-grpc Docker images set OMP_THREAD_LIMIT=1:
6+
to avoid oversubscribing the CPU under forked parse workers (TIKA-4863).
117

128
* embedded-limits maxDepth counts embedding levels again instead of the
139
parsers a parse passes through; with AutoDetectParser over DefaultParser

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

Lines changed: 81 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,26 @@
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
6971
it 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

18076
Per-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

328263
Three 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
391326
diagnostic: wrap the parser's `TikaInputStream` so any `getFile()`/`getPath()`
392327
call is recorded, and assert none happened. A watched temp directory is not
393328
enough — 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.

tika-grpc/docker-build/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ USER $UID_GID
5656

5757
EXPOSE 9090
5858
ENV TIKA_VERSION=$VERSION
59+
# tesseract links libgomp; one thread per OCR process - parallelism comes from the forked parse workers
60+
ENV OMP_THREAD_LIMIT=1
5961
ENV TIKA_GRPC_MAX_INBOUND_MESSAGE_SIZE=$TIKA_GRPC_MAX_INBOUND_MESSAGE_SIZE
6062
ENV TIKA_GRPC_MAX_OUTBOUND_MESSAGE_SIZE=$TIKA_GRPC_MAX_OUTBOUND_MESSAGE_SIZE
6163
ENV TIKA_GRPC_NUM_THREADS=$TIKA_GRPC_NUM_THREADS

tika-server/docker-build/full/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ RUN set -eux \
7373
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
7474
ARG TIKA_VERSION
7575
ENV TIKA_VERSION=$TIKA_VERSION
76+
# tesseract links libgomp; one thread per OCR process - parallelism comes from the forked parse workers
77+
ENV OMP_THREAD_LIMIT=1
7678

7779
COPY --from=fetch_tika /opt/tika-server /opt/tika-server
7880
# WORKDIR sets the CWD so tika-server's plugin-root fallback resolves

tika-server/docker-build/full/Dockerfile.snapshot

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ RUN set -eux \
4444
&& apt-get clean -y \
4545
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
4646
ENV TIKA_VERSION=$TIKA_VERSION
47+
# tesseract links libgomp; one thread per OCR process - parallelism comes from the forked parse workers
48+
ENV OMP_THREAD_LIMIT=1
4749
# Snapshot workflow tars the bin distribution into <context>/tika-server/, so
4850
# this COPY lands the thin jar + lib/ + plugins/ at /opt/tika-server/, matching
4951
# the release-variant Dockerfile.

0 commit comments

Comments
 (0)