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 d016f8aaed TIKA-4839 - add micrometer to tika-server (#3064)
d016f8aaed is described below
commit d016f8aaedd1f265e0dabb30994daef0b5194b71
Author: Tim Allison <[email protected]>
AuthorDate: Wed Aug 26 19:45:04 2026 -0400
TIKA-4839 - add micrometer to tika-server (#3064)
---
CHANGES.txt | 2 +
docs/modules/ROOT/nav.adoc | 1 +
.../ROOT/pages/using-tika/server/index.adoc | 16 +-
.../ROOT/pages/using-tika/server/monitoring.adoc | 207 ++++++++++++++++
tika-parent/pom.xml | 16 ++
.../tika/pipes/core/PerClientServerManager.java | 50 +++-
.../org/apache/tika/pipes/core/PipesClient.java | 24 +-
.../org/apache/tika/pipes/core/PipesParser.java | 14 ++
.../org/apache/tika/pipes/core/RestartCounter.java | 105 ++++++++
.../org/apache/tika/pipes/core/RestartReason.java | 36 +++
.../org/apache/tika/pipes/core/ServerManager.java | 76 ++++--
.../tika/pipes/core/SharedServerManager.java | 103 +++++---
.../tika/pipes/core/async/AsyncProcessor.java | 11 +
.../apache/tika/pipes/core/server/PipesServer.java | 6 +-
.../apache/tika/pipes/core/RestartCounterTest.java | 125 ++++++++++
.../pipes/core/ServerManagerMarkContractTest.java | 150 ++++++++++++
.../tika/pipes/core/SharedServerModeTest.java | 26 ++
tika-server/tika-server-core/pom.xml | 12 +
.../org/apache/tika/server/core/ServerStatus.java | 5 +
.../org/apache/tika/server/core/TikaServerCli.java | 3 +
.../apache/tika/server/core/TikaServerConfig.java | 29 +++
.../apache/tika/server/core/TikaServerProcess.java | 69 +++++-
.../tika/server/core/metrics/MetricsServer.java | 113 +++++++++
.../server/core/metrics/TikaMetricsFilter.java | 95 ++++++++
.../server/core/metrics/TikaServerMetrics.java | 204 ++++++++++++++++
.../tika/server/core/resource/AsyncResource.java | 4 +
.../tika/server/core/TikaServerConfigTest.java | 56 +++++
.../core/TikaServerMetricsIntegrationTest.java | 247 +++++++++++++++++++
.../server/core/metrics/MetricsServerTest.java | 96 ++++++++
.../server/core/metrics/RejectedReasonTest.java | 65 +++++
.../server/core/metrics/TikaMetricsFilterTest.java | 269 +++++++++++++++++++++
.../configs/tika-config-server-async-metrics.json | 24 ++
.../tika-config-server-metrics-restarts.json | 18 ++
.../configs/tika-config-server-metrics-shared.json | 17 ++
.../tika-config-server-metrics-timeout.json | 21 ++
.../configs/tika-config-server-metrics.json | 10 +
.../src/main/appended-resources/META-INF/LICENSE | 84 +++++++
37 files changed, 2327 insertions(+), 82 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index b7514b3cc7..d517042ccb 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,7 @@
Release 4.1.0 - unreleased
+ * Add Micrometer reporting and opt-in endpoint for tika-server (TIKA-4839).
+
* Improve spooling/decrease number of spills to disk (TIKA-4835).
* Fixed a bug that made per-request (parse-context) configuration unusable
diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc
index fe2b5105f7..fe609b2e9a 100644
--- a/docs/modules/ROOT/nav.adoc
+++ b/docs/modules/ROOT/nav.adoc
@@ -20,6 +20,7 @@
** xref:using-tika/cli/index.adoc[Command Line]
** xref:using-tika/server/index.adoc[Tika Server]
*** xref:using-tika/server/tls.adoc[TLS/SSL Configuration]
+*** xref:using-tika/server/monitoring.adoc[Monitoring with Prometheus]
** xref:using-tika/grpc/index.adoc[gRPC]
** xref:using-tika/docker.adoc[Running Tika in Docker]
* xref:pipes/index.adoc[Pipes]
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index a156fa187f..4d291ad840 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -56,6 +56,9 @@ moment" apart from "this document broke a fork" and act
accordingly: back off an
their fetch rate, and autoscalers have a signal to scale on — none of which is
possible against a
server that can only get slower or fail blind.
+The same signal is available as metrics -- busy workers, `429` rate, worker
restarts by reason --
+when a metrics port is set; see
xref:using-tika/server/monitoring.adoc[Monitoring with Prometheus].
+
The cost is real and lands on you: backpressure only reports the capacity you
configured.
`numClients` is now an operational obligation, sized against both your request
volume and your
host's core count, as the note above says — undersized shows up as `429`s
under load that should
@@ -206,11 +209,15 @@ The server starts on `localhost:9998` by default.
|`-i <id>`, `--id <id>`
|Server ID, written to the startup log. Defaults to a random UUID.
+|`--metricsPort <port>`
+|Enable Prometheus metrics on this port. See
+xref:using-tika/server/monitoring.adoc[Monitoring with Prometheus].
+
|`-?`, `--help`
|Print the usage message.
|===
-NOTE: `-h`, `-p` and `-i` override the JSON config. Everything else —
`allowPipes`,
+NOTE: `-h`, `-p`, `-i` and `--metricsPort` override the JSON config.
Everything else — `allowPipes`,
`allowPerRequestConfig`, CORS, TLS, timeouts — is JSON-only.
== Endpoints
@@ -516,6 +523,11 @@ changes no other log level.
|`tlsConfig`
|_TLS off_
|Nested TLS/mTLS settings. See xref:using-tika/server/tls.adoc[TLS/SSL
Configuration].
+
+|`metricsPort`
+|_metrics off_
+|Serve Prometheus metrics on this port (same setting as `--metricsPort`). See
+xref:using-tika/server/monitoring.adoc[Monitoring with Prometheus].
|===
NOTE: Digests are configured in `parse-context`, not in `server`. See
@@ -574,5 +586,7 @@ explicitly with the combined total in mind).
== Topics
* xref:using-tika/server/tls.adoc[TLS/SSL Configuration] — TLS and mutual
authentication
+* xref:using-tika/server/monitoring.adoc[Monitoring with Prometheus] — metrics
for
+autoscaling and alerting
* xref:migration-to-4x/migrating-tika-server-4x.adoc[Migrating Tika Server to
4.x] — breaking
changes from 3.x
diff --git a/docs/modules/ROOT/pages/using-tika/server/monitoring.adoc
b/docs/modules/ROOT/pages/using-tika/server/monitoring.adoc
new file mode 100644
index 0000000000..6374aee862
--- /dev/null
+++ b/docs/modules/ROOT/pages/using-tika/server/monitoring.adoc
@@ -0,0 +1,207 @@
+//
+// 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.
+//
+
+= Monitoring with Prometheus
+:toc:
+:toclevels: 3
+
+Tika Server can publish operational metrics in the Prometheus text exposition
format,
+via https://micrometer.io[Micrometer]. The metrics are built for two questions
an operator
+of a parse fleet actually has: _is this server saturated_ (scale on that, not
on CPU) and
+_are its forked workers dying_ (alert on that).
+
+== Enabling
+
+Metrics are off by default. Setting a metrics port turns them on; nothing else
is needed.
+
+[source,bash]
+----
+java -jar tika-server-standard-X.Y.Z.jar --metricsPort 9404
+----
+
+or in `tika-config.json`:
+
+[source,json]
+----
+{
+ "server": {
+ "port": 9998,
+ "metricsPort": 9404
+ }
+}
+----
+
+`--metricsPort` on the command line overrides the JSON value. The port must
differ from
+the server port; `0` picks a free port, which is written to the startup log.
+
+The scrape listener binds to the server's own host (`-h`), so a server started
with
+`-h 0.0.0.0` in a container exposes its metrics on the pod IP with no extra
+configuration. Labels such as a cluster or tier name belong in your scrape
configuration
+(`relabel_configs`, or the `ServiceMonitor` under the Prometheus Operator),
not in Tika.
+
+Metrics are served on a *separate port* from the parse endpoints, on purpose.
The parse
+port receives untrusted documents; the metrics port should be reachable only
by your
+scraper. Keep them apart at the network layer (a Kubernetes `NetworkPolicy`, a
security
+group). The scrape listener serves only `/metrics` (`GET` or `HEAD`) -- every
parse
+endpoint is `404` there, and `/metrics` is `404` on the parse port. It also
has its own small thread pool
+and a cap of 64 open connections, so a scrape never waits behind a slow parse.
+
+The scrape listener is plain HTTP even when the parse port uses
+xref:using-tika/server/tls.adoc[TLS].
+
+== Probes
+
+Point Kubernetes liveness and readiness probes at the parse port (for example
+`GET /version`, or `GET /status` when that endpoint is enabled), never at
`/metrics`. A
+scrape target is not a health check: the listener stays up while the parser is
failing,
+and a metrics misconfiguration must not take a healthy parser out of rotation.
+
+== Scrape configuration
+
+[source,yaml]
+----
+scrape_configs:
+ - job_name: tika-server
+ static_configs:
+ - targets: ['tika-1:9404', 'tika-2:9404']
+----
+
+For the Prometheus Operator, a `ServiceMonitor` selecting a Service that
exposes the
+metrics port works the same way.
+
+== Meters
+
+Durations are Micrometer timers exported in seconds with a fixed histogram of
twelve
+buckets (10ms, 50ms, 100ms, 250ms, 500ms, 1s, 2s, 5s, 10s, 30s, 60s, 120s), so
+`histogram_quantile` works and the series count per pod stays small. Every
label value is
+drawn from a fixed set -- endpoint names, status classes, enum names -- never
from the
+request, so cardinality cannot grow with traffic.
+
+=== HTTP (parse port)
+
+[cols="2,1,2,3"]
+|===
+|Meter |Type |Labels |Meaning
+
+|`tika_server_requests_seconds`
+|timer
+|`endpoint`, `method`, `status`
+|Every request on the parse port, timed from before routing until the response
is
+returned -- the parse is inside that window, streaming the response body to
the client is
+not. `endpoint` is the first path segment when it is one of the server's
endpoints
+(`tika`, `rmeta`, `meta`, `unpack`, `detect`, `language`, `mime`, `mime-types`,
+`detectors`, `parsers`, `version`, `status`, `pipes`, `async`), `other` for
any other
+resource, `unmatched` for a request answered before routing (a `404`).
`method` is the
+HTTP verb when it is a standard one, else `other`. `status` is the class:
`2xx`, `3xx`,
+`4xx`, `5xx`, or `other` for a status outside 200-599.
+
+|`tika_server_request_size_bytes`
+|summary
+|`endpoint`
+|Request body size as declared by the client's `Content-Length`, not bytes
actually read;
+chunked uploads have none and are not recorded. Bucketed at 1KB..1GB by
decades.
+
+|`tika_server_rejected_total`
+|counter
+|`reason`
+|Requests refused for capacity reasons, by the status the server already uses
to encode
+them: `busy_429` (the `/async` queue was full, or no fork was free within
+`maxWaitForClientMillis`), `crash_503` (the fork serving the request OOM'd,
timed out or
+crashed), `payload_413` (body over `maxRequestSizeBytes` or the IPC payload
limit).
+
+|`tika_server_tasks_active`
+|gauge
+|
+|Sync parse/detect tasks (`/tika`, `/rmeta`, `/meta`, `/detect`) in flight
right now;
+`/unpack`, `/pipes` and `/async` work is not included.
+|===
+
+=== Forked workers
+
+[cols="2,1,2,3"]
+|===
+|Meter |Type |Labels |Meaning
+
+|`tika_pipes_workers`
+|gauge
+|`pool`, `state`
+|Pipes worker slots `busy` and `idle`. Only `pool="sync"` has these: busy/idle
needs a
+borrowable client queue, which the `/async` pool does not have. Under
`useSharedServer`
+these are client slots against one forked JVM, not JVMs. `busy / (busy +
idle)` sustained
+near 1 means the server is at capacity; see
xref:pipes/cpu-sizing.adoc[Forked-JVM CPU and
+Heap Sizing] before raising `numClients`.
+
+|`tika_pipes_worker_restarts_total`
+|counter
+|`pool`, `reason`
+|Forked workers restarted, by why: `oom`, `timeout`, `crash` (any other
failure,
+including an IPC error the server could not attribute), `max_files` (routine
recycling
+after `maxFilesProcessedPerProcess`), `idle` (the worker shut itself down after
+`socketTimeoutMillis` without a request -- exit code 24 -- and was started
again on the
+next one; per-client mode only, a shared server stays up when idle),
+`connection_abandoned` (the client dropped the connection: a request was
interrupted, or a
+worker reply exceeded `maxIpcPayloadBytes`), `shutdown` (the parent asked the
worker to stop --
+usually after a failed health check prompted a reconnect -- and it exited
cleanly).
+Alert on `oom`, `timeout` and `crash`; the rest are expected.
+
+`pool` separates two independent sets of forks: `sync` serves `/tika`,
`/rmeta`, `/meta`,
+`/unpack`, `/detect` and `/pipes`; `async` serves `/async`. Each pool is sized
by its own
+`numClients` and is present only when its endpoints are. Sum over `pool`
unless you mean
+one of them specifically.
+
+|`tika_pipes_queue_depth`
+|gauge
+|`pool`
+|Tuples accepted by `/async` and not yet picked up by a worker. Only
`pool="async"`
+exists today; present only when the `async` endpoint is enabled.
+|===
+
+=== JVM and process
+
+`jvm_memory_*`, `jvm_buffer_*`, `jvm_threads_*`, `process_files_*`,
+`process_start_time_seconds` and `process_uptime_seconds`.
+
+These describe *this* JVM, which routes requests and holds the results coming
back over
+IPC. It is not where documents are parsed: that happens in forked workers this
server
+starts and restarts, and no meter on this page except `tika_pipes_*` sees
inside them.
+A near-idle heap here is not evidence of headroom.
+
+There is deliberately no GC or CPU binder. Both describe a process that does
not parse,
+and both invite that false read. For CPU that actually covers the workers, use
the
+container/node metrics your cluster already collects (cAdvisor,
node-exporter); for
+worker health use `tika_pipes_worker_restarts_total` and the saturation
signals below.
+
+== What to scale and alert on
+
+* Saturation, for an autoscaler: on a sync workload,
`tika_pipes_workers{state="busy"}` as
+ a ratio of the total and the rate of
`tika_server_rejected_total{reason="busy_429"}`.
+ On an `/async` workload use `tika_pipes_queue_depth` instead -- there is no
busy/idle
+ gauge for that pool, and the sync gauge sits at 0 while `/async` saturates.
These move
+ before latency does, which CPU does not.
+* Failure, for alerting:
+ `sum by (reason)
(rate(tika_pipes_worker_restarts_total{reason=~"oom|timeout|crash"}[5m]))`
+ -- summed over `pool`, so async workers are included -- and
+ `tika_server_rejected_total{reason="crash_503"}`. A `503` tells the client
the
+ document broke a fork; the restart counter tells you how often that is
happening.
+* Latency: `histogram_quantile(0.95, sum by (le, endpoint)
(rate(tika_server_requests_seconds_bucket[5m])))`.
+
+== Not counted
+
+An exception that no JAX-RS `ExceptionMapper` handles is answered by the
servlet
+container's own error path, which bypasses the response filter that records
+`tika_server_requests_seconds`. Tika Server maps its own parse and pipes
failures, so this
+only affects genuine server bugs.
diff --git a/tika-parent/pom.xml b/tika-parent/pom.xml
index 1fd35ecfe3..673c84cb95 100644
--- a/tika-parent/pom.xml
+++ b/tika-parent/pom.xml
@@ -404,6 +404,7 @@
<lucene.version>9.12.3</lucene.version>
<maven.plugin.annotations.version>3.15.2</maven.plugin.annotations.version>
<metadata.extractor.version>2.21.0</metadata.extractor.version>
+ <micrometer.version>1.17.1</micrometer.version>
<microsoft.translator.version>0.6.2</microsoft.translator.version>
<!-- can't update to 4 because Apache Ignite needs
io.micronaut.core.convert.DefaultConversionService
which no longer exists in 4.0:
https://stackoverflow.com/questions/79937141/ -->
@@ -593,6 +594,16 @@
<artifactId>jetty-http2-server</artifactId>
<version>${jetty.http2.version}</version>
</dependency>
+ <dependency>
+ <groupId>io.micrometer</groupId>
+ <artifactId>micrometer-core</artifactId>
+ <version>${micrometer.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>io.micrometer</groupId>
+ <artifactId>micrometer-registry-prometheus</artifactId>
+ <version>${micrometer.version}</version>
+ </dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
@@ -1248,6 +1259,11 @@
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-params</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
index e5e6cc83eb..b381251d8c 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java
@@ -180,6 +180,7 @@ public class PerClientServerManager implements
ServerManager {
private volatile int port = -1;
private long filesProcessed = 0;
private volatile boolean pendingRestart = false;
+ private final RestartCounter restarts = new RestartCounter();
// Set once by shutdown()/close(); guards a request thread from starting a
fresh
// process after the manager has been torn down (which would leak the
child).
private volatile boolean closed = false;
@@ -281,7 +282,7 @@ public class PerClientServerManager implements
ServerManager {
if (filesProcessed >= maxFilesPerProcess) {
LOG.info("clientId={}: reached max files limit ({}/{}), marking
for restart",
clientId, filesProcessed, maxFilesPerProcess);
- pendingRestart = true;
+ markForRestart(RestartReason.MAX_FILES);
}
}
@@ -292,18 +293,34 @@ public class PerClientServerManager implements
ServerManager {
@Override
public void markServerForRestart() {
- LOG.info("clientId={}: marking server for restart", clientId);
+ markServerForRestart(RestartReason.CRASH);
+ }
+
+ @Override
+ public void markServerForRestart(RestartReason reason) {
+ LOG.info("clientId={}: marking server for restart ({})", clientId,
reason);
+ markForRestart(reason);
+ }
+
+ private void markForRestart(RestartReason reason) {
+ restarts.mark(reason);
pendingRestart = true;
}
+ @Override
+ public long getRestartCount(RestartReason reason) {
+ return restarts.count(reason);
+ }
+
@Override
public void connectionAbandoned() {
LOG.info("clientId={}: connection abandoned, worker will be recycled
on next use", clientId);
- pendingRestart = true;
+ markForRestart(RestartReason.CONNECTION_ABANDONED);
}
@Override
public int handleCrashAndGetExitCode() {
+ // Not marked: RestartCounter attributes by exit code; the caller
refines OOM/TIMEOUT.
pendingRestart = true;
if (process != null) {
try {
@@ -311,7 +328,10 @@ public class PerClientServerManager implements
ServerManager {
if (!process.isAlive()) {
int exitValue = process.exitValue();
if (exitValue == 0) {
- LOG.info("clientId={}: process exited cleanly",
clientId);
+ LOG.warn("clientId={}: process exited with code 0
without a shutdown request; " +
+ "counted as a crash restart", clientId);
+ } else if (exitValue == PipesServer.IDLE_EXIT_CODE) {
+ LOG.info("clientId={}: process exited after idle
timeout", clientId);
} else {
LOG.warn("clientId={}: process exited with code {}",
clientId, exitValue);
}
@@ -335,7 +355,15 @@ public class PerClientServerManager implements
ServerManager {
if (isRunning() && !pendingRestart) {
return;
}
- startServer();
+ Process previous = process;
+ try {
+ startServer();
+ } finally {
+ // Count the old process once it is really gone, even if the new
start failed.
+ if (process != previous) {
+ restarts.restarted(previous);
+ }
+ }
}
@Override
@@ -366,9 +394,15 @@ public class PerClientServerManager implements
ServerManager {
}
if (!p.isAlive()) {
int exitValue = p.exitValue();
- LOG.error("clientId={}: Process exited with code {} before
connecting to socket",
- clientId, exitValue);
- ServerProcessIO.surfaceCrashDiagnostics(LOG, "clientId=" +
clientId, tmpDir);
+ if (exitValue == 0 || exitValue ==
PipesServer.IDLE_EXIT_CODE) {
+ // Idle/SHUT_DOWN exit raced the reconnect; not a
crash.
+ LOG.info("clientId={}: process exited with code {}
before connecting",
+ clientId, exitValue);
+ } else {
+ LOG.error("clientId={}: Process exited with code {}
before connecting to socket",
+ clientId, exitValue);
+ ServerProcessIO.surfaceCrashDiagnostics(LOG,
"clientId=" + clientId, tmpDir);
+ }
// Always treat pre-connect death as retryable.
// The only non-retryable paths are:
// 1. pb.start() fails (can't launch process) - handled in
startServer()
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java
index c4743e1a65..77339145f5 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java
@@ -85,8 +85,8 @@ public class PipesClient implements Closeable {
/**
* The server generation this client's connection belongs to, captured in
{@link #reconnect()}.
* Starts at MAX_VALUE so a report made before we have ever connected is
never mistaken for a
- * stale one: dropping a legitimate report can wedge the pool, while an
extra one only costs a
- * restart, so the un-connected case must fail toward reporting.
+ * stale one: dropping a legitimate mark can wedge the pool, while an
extra mark only costs a
+ * restart, so the un-connected case must fail toward marking.
*/
private volatile long connectionGeneration = Long.MAX_VALUE;
private int filesProcessed = 0;
@@ -225,11 +225,13 @@ public class PipesClient implements Closeable {
throw e;
} catch (ServerInitializationException e) {
LOG.error("server initialization failed: {} ", t.getId(), e);
+ serverManager.markServerForRestart(RestartReason.CRASH,
connectionGeneration);
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(),
PipesResult.RESULT_STATUS.FAILED_TO_INITIALIZE,
intermediateResult.get(), e.getMessage());
} catch (SecurityException e) {
LOG.error("security exception during initialization: {} ",
t.getId());
+ serverManager.markServerForRestart(RestartReason.CRASH,
connectionGeneration);
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(),
PipesResult.RESULT_STATUS.FAILED_TO_INITIALIZE,
intermediateResult.get());
@@ -258,6 +260,7 @@ public class PipesClient implements Closeable {
intermediateResult.get(), e.getMessage());
} catch (Exception e) {
LOG.error("exception waiting for server to complete task: {} ",
t.getId(), e);
+ serverManager.markServerForRestart(RestartReason.CRASH,
connectionGeneration);
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(),
UNSPECIFIED_CRASH, intermediateResult.get());
}
@@ -332,8 +335,8 @@ public class PipesClient implements Closeable {
// Get port after ensureRunning - this is the port we'll connect to
int port = serverManager.getPort();
- // Captured with the port: every report we make below is about THIS
process, and must be
- // dropped if a sibling has already replaced it.
+ // Captured with the port: every report we make below is about THIS
process, and must
+ // be dropped if a sibling has already replaced it.
connectionGeneration = serverManager.getGeneration();
LOG.debug("pipesClientId={}: connecting to server", pipesClientId);
@@ -414,7 +417,7 @@ public class PipesClient implements Closeable {
LOG.warn("clientId={}: client-side backstop timeout: id={}
elapsed={}ms limit={}ms " +
"-- server should have self-terminated well
before this", pipesClientId,
t.getId(), totalElapsed, clientBackstopMillis);
- serverManager.markServerForRestart(connectionGeneration);
+ serverManager.markServerForRestart(RestartReason.TIMEOUT,
connectionGeneration);
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(), TIMEOUT,
intermediateResult.get());
}
@@ -430,19 +433,19 @@ public class PipesClient implements Closeable {
switch (msg.type()) {
case OOM:
String oomMsg = JsonPipesIpc.fromBytes(msg.payload(),
String.class);
-
serverManager.markServerForRestart(connectionGeneration);
+ serverManager.markServerForRestart(RestartReason.OOM,
connectionGeneration);
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(),
PipesResult.RESULT_STATUS.OOM,
intermediateResult.get(), oomMsg);
case TIMEOUT:
String timeoutMsg =
JsonPipesIpc.fromBytes(msg.payload(), String.class);
-
serverManager.markServerForRestart(connectionGeneration);
+
serverManager.markServerForRestart(RestartReason.TIMEOUT, connectionGeneration);
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(),
TIMEOUT,
intermediateResult.get(), timeoutMsg);
case UNSPECIFIED_CRASH:
String crashMsg =
JsonPipesIpc.fromBytes(msg.payload(), String.class);
-
serverManager.markServerForRestart(connectionGeneration);
+
serverManager.markServerForRestart(RestartReason.CRASH, connectionGeneration);
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(),
UNSPECIFIED_CRASH,
intermediateResult.get(), crashMsg);
@@ -476,13 +479,14 @@ public class PipesClient implements Closeable {
} catch (SocketTimeoutException e) {
LOG.warn("clientId={}: Socket timeout exception while waiting
for server", pipesClientId, e);
// Mark for restart - server is stuck on current request and
needs to be restarted
- serverManager.markServerForRestart(connectionGeneration);
+ serverManager.markServerForRestart(RestartReason.TIMEOUT,
connectionGeneration);
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(), TIMEOUT,
intermediateResult.get(),
ExceptionUtils.getStackTrace(e));
} catch (PayloadLimitExceededException e) {
// Stream is desynchronized (payload bytes were not consumed);
close the connection.
LOG.warn("clientId={}: payload too large for id={}: {}",
pipesClientId, t.getId(), e.getMessage());
+ serverManager.connectionAbandoned();
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(),
PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED,
@@ -496,8 +500,10 @@ public class PipesClient implements Closeable {
PipesResult.RESULT_STATUS status = UNSPECIFIED_CRASH;
if (exitCode == PipesMessageType.OOM.getExitCode().orElse(-1))
{
status = PipesResult.RESULT_STATUS.OOM;
+ serverManager.markServerForRestart(RestartReason.OOM,
connectionGeneration);
} else if (exitCode ==
PipesMessageType.TIMEOUT.getExitCode().orElse(-1)) {
status = PipesResult.RESULT_STATUS.TIMEOUT;
+ serverManager.markServerForRestart(RestartReason.TIMEOUT,
connectionGeneration);
}
closeConnection();
return buildFatalResult(t.getId(), t.getEmitKey(), status,
intermediateResult.get(),
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesParser.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesParser.java
index a03e24efe5..d154a5962e 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesParser.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesParser.java
@@ -168,6 +168,20 @@ public class PipesParser implements Closeable {
return
serverManagers.stream().filter(ServerManager::isRunning).count();
}
+ public int getNumClients() {
+ return pipesConfig.getNumClients();
+ }
+
+ /** Clients not currently borrowed by a {@link #parse} call. */
+ public int getIdleClientCount() {
+ return clientQueue.size();
+ }
+
+ /** Forked-server restarts performed so far for {@code reason}, summed
over all servers. */
+ public long getRestartCount(RestartReason reason) {
+ return serverManagers.stream().mapToLong(m ->
m.getRestartCount(reason)).sum();
+ }
+
/**
* Returns whether this parser is using shared server mode.
*
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/RestartCounter.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/RestartCounter.java
new file mode 100644
index 0000000000..3c3979a728
--- /dev/null
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/RestartCounter.java
@@ -0,0 +1,105 @@
+/*
+ * 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.
+ */
+package org.apache.tika.pipes.core;
+
+import java.util.EnumMap;
+import java.util.concurrent.atomic.LongAdder;
+
+import org.apache.tika.pipes.core.protocol.PipesMessageType;
+import org.apache.tika.pipes.core.server.PipesServer;
+
+/**
+ * Reason marked while a restart is pending, plus counts of restarts
performed, by reason.
+ * Last mark wins; an unmarked restart is attributed by the old process's exit
code.
+ */
+final class RestartCounter {
+
+ private final EnumMap<RestartReason, LongAdder> counts = new
EnumMap<>(RestartReason.class);
+ private volatile RestartReason pending;
+
+ RestartCounter() {
+ for (RestartReason r : RestartReason.values()) {
+ counts.put(r, new LongAdder());
+ }
+ }
+
+ void mark(RestartReason reason) {
+ pending = reason;
+ }
+
+ /** Marks {@code reason} unless a more specific one is already pending. */
+ void markIfUnmarked(RestartReason reason) {
+ if (pending == null) {
+ pending = reason;
+ }
+ }
+
+ /**
+ * Records a restart of {@code previous}. Null means there was no process
to restart
+ * (first start, or the last start failed): nothing is counted and any
mark is dropped,
+ * since it referred to a restart that never happened.
+ */
+ void restarted(Process previous) {
+ if (previous == null) {
+ pending = null;
+ return;
+ }
+ restarted(previous.isAlive() ? -1 : previous.exitValue());
+ }
+
+ /** Unmarked restarts are attributed by the exit code the child chose. */
+ void restarted(int exitCode) {
+ RestartReason reason = pending;
+ pending = null;
+ if (reason == null) {
+ reason = fromExitCode(exitCode);
+ }
+ counts.get(reason).increment();
+ }
+
+ /**
+ * The child reports why it died in its exit status, and the parent may
reach a restart
+ * without ever having read the corresponding frame (the socket can break
first, or the
+ * child can outlive the parent's one-second wait). Every code the child
can deliberately
+ * choose is honoured here; only genuinely unexplained deaths fall through
to CRASH.
+ */
+ private static RestartReason fromExitCode(int exitCode) {
+ if (exitCode == PipesServer.IDLE_EXIT_CODE) {
+ return RestartReason.IDLE;
+ }
+ if (exitCode == 0) {
+ // Only PipesServer's SHUT_DOWN handler exits 0, and the parent is
the only sender:
+ // a worker we asked to stop is not a crash, however we noticed it
was gone.
+ return RestartReason.SHUTDOWN;
+ }
+ if (matches(PipesMessageType.OOM, exitCode)) {
+ return RestartReason.OOM;
+ }
+ if (matches(PipesMessageType.TIMEOUT, exitCode)) {
+ return RestartReason.TIMEOUT;
+ }
+ return RestartReason.CRASH;
+ }
+
+ private static boolean matches(PipesMessageType type, int exitCode) {
+ return type.getExitCode().orElse(Integer.MIN_VALUE) == exitCode;
+ }
+
+ long count(RestartReason reason) {
+ return counts.get(reason).sum();
+ }
+}
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/RestartReason.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/RestartReason.java
new file mode 100644
index 0000000000..e093296007
--- /dev/null
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/RestartReason.java
@@ -0,0 +1,36 @@
+/*
+ * 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.
+ */
+package org.apache.tika.pipes.core;
+
+/**
+ * Why a forked pipes server was (or will be) restarted. Bounded on purpose:
these
+ * become metric tag values.
+ */
+public enum RestartReason {
+ OOM,
+ TIMEOUT,
+ CRASH,
+ MAX_FILES,
+ CONNECTION_ABANDONED,
+ /** The worker exited on its own (exit 24) after sitting idle for
socketTimeoutMillis. */
+ IDLE,
+ /**
+ * The worker exited cleanly (exit 0) because the parent sent it SHUT_DOWN
-- typically after a
+ * failed health check prompted a reconnect. The replacement is
deliberate, not a failure.
+ */
+ SHUTDOWN
+}
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ServerManager.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ServerManager.java
index b40d8c4757..f620cda3c0 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ServerManager.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ServerManager.java
@@ -103,11 +103,57 @@ public interface ServerManager extends Closeable {
* might still return true briefly. The next call to {@link
#ensureRunning()} will
* wait for the process to fully exit and then restart.
* <p>
- * In per-client mode, this is typically a no-op since the client owns the
server.
- * In shared mode, this is important for coordinating restarts among
multiple clients.
+ * The reason form below defaults to this one, so this must NOT default to
the reason form:
+ * an implementation overriding neither would recurse until the stack
blew. Concrete managers
+ * in tika-pipes override both, so callers of either spelling reach a real
implementation.
*/
default void markServerForRestart() {
- // Default no-op for backward compatibility
+ // Default no-op: preserves implementations written before
RestartReason existed.
+ }
+
+ /** As {@link #markServerForRestart()}, attributing the restart to {@code
reason}. Override this one. */
+ default void markServerForRestart(RestartReason reason) {
+ markServerForRestart();
+ }
+
+ /**
+ * The generation of the currently running process: a counter incremented
every time this
+ * manager forks a replacement. A client captures it when it connects and
hands it back with
+ * every report, so a report about a process that has already been
replaced can be recognised
+ * and dropped rather than being applied to its healthy successor.
+ */
+ default long getGeneration() {
+ return 0;
+ }
+
+ /**
+ * As {@link #markServerForRestart(RestartReason)}, but only if {@code
generation} is still
+ * current. Reports about a superseded process are dropped.
+ */
+ default void markServerForRestart(RestartReason reason, long generation) {
+ markServerForRestart(reason);
+ }
+
+ /**
+ * The reasonless spelling of the above, kept for callers that cannot
attribute the failure.
+ * Routed through the reason form rather than the bare no-arg default:
that default exists
+ * only to keep pre-RestartReason implementations working, and delegating
here would leave
+ * this silently inert for any implementation that overrides only the
reason form.
+ */
+ default void markServerForRestart(long generation) {
+ markServerForRestart(RestartReason.CRASH, generation);
+ }
+
+ /**
+ * As {@link #handleCrashAndGetExitCode()}, but only if {@code generation}
is still current.
+ */
+ default int handleCrashAndGetExitCode(long generation) {
+ return handleCrashAndGetExitCode();
+ }
+
+ /** Restarts performed so far for {@code reason}; monotonic, never reset.
*/
+ default long getRestartCount(RestartReason reason) {
+ return 0;
}
/**
@@ -160,30 +206,8 @@ public interface ServerManager extends Closeable {
* @return the exit code if available, or -1 if the process is still
running or unavailable
*/
default int handleCrashAndGetExitCode() {
- markServerForRestart();
+ markServerForRestart(RestartReason.CRASH);
return -1;
}
- /**
- * The generation of the currently running process: a counter incremented
every time this
- * manager forks a replacement. A client captures it when it connects and
hands it back with
- * every report, so a report about a process that has already been
replaced can be recognised
- * and dropped rather than being applied to its healthy successor.
- */
- default long getGeneration() {
- return 0;
- }
-
- /**
- * As {@link #markServerForRestart()}, but only if {@code generation} is
still current.
- * Reports about a superseded process are dropped.
- */
- default void markServerForRestart(long generation) {
- markServerForRestart();
- }
-
- /** As {@link #handleCrashAndGetExitCode()}, but only if {@code
generation} is still current. */
- default int handleCrashAndGetExitCode(long generation) {
- return handleCrashAndGetExitCode();
- }
}
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java
index 93b4f03828..17cdf4f6ff 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java
@@ -76,10 +76,11 @@ public class SharedServerManager implements ServerManager {
private final Object lock = new Object();
private final AtomicLong filesProcessed = new AtomicLong(0);
private volatile boolean restarting = false;
- private volatile boolean pendingRestart = false;
- private volatile long generation;
- private volatile boolean closed; // Set when a client reports fatal error
or max files reached
+ private volatile boolean pendingRestart = false; // Set when a client
reports fatal error or max files reached
+ private final RestartCounter restarts = new RestartCounter();
private volatile byte[] currentToken;
+ private volatile long generation;
+ private volatile boolean closed;
private Process process;
private Path tmpDir;
private int serverPort = -1;
@@ -147,12 +148,17 @@ public class SharedServerManager implements ServerManager
{
throw new IllegalStateException("shared server manager is
closed");
}
restarting = true;
+ Process previous = process;
try {
startServer();
generation++; // supersedes every report still in flight
against the old process
pendingRestart = false; // Clear the flag after successful
restart
filesProcessed.set(0); // Reset file counter on restart
} finally {
+ // Count the old process once it is really gone, even if the
new start failed.
+ if (process != previous) {
+ restarts.restarted(previous);
+ }
restarting = false;
lock.notifyAll(); // Wake up any threads waiting in getPort()
}
@@ -160,21 +166,53 @@ public class SharedServerManager implements ServerManager
{
}
/**
- * Marks the server for restart due to a fatal error (OOM, timeout).
- * <p>
- * This is called by clients when they receive OOM or TIMEOUT status.
- * It signals that the server process is stopping (System.exit was called),
- * even if isRunning() might still return true briefly.
- * <p>
- * The next call to ensureRunning() will wait for the process to fully
- * exit and then restart the server.
+ * Called by a client that received OOM or TIMEOUT: the process is exiting
even if
+ * isRunning() still says otherwise; the next ensureRunning() restarts it.
*/
@Override
public void markServerForRestart() {
+ markServerForRestart(RestartReason.CRASH);
+ }
+
+ @Override
+ public void markServerForRestart(RestartReason reason) {
synchronized (lock) {
- LOG.debug("Server marked for restart - will restart on next
ensureRunning()");
- pendingRestart = true;
+ LOG.debug("Server marked for restart ({}) - will restart on next
ensureRunning()", reason);
+ markForRestart(reason);
+ }
+ }
+
+ @Override
+ public void markServerForRestart(RestartReason reason, long generation) {
+ synchronized (lock) {
+ if (isSuperseded(generation, reason)) {
+ return;
+ }
+ LOG.debug("Server marked for restart ({}) - will restart on next
ensureRunning()", reason);
+ markForRestart(reason);
+ }
+ }
+
+ /**
+ * A restart kills the shared JVM out from under every sibling still
parsing on it, and
+ * {@code ensureRunning} holds {@code lock} for the whole fork, so those
siblings cannot
+ * report until after the replacement is up. Their reports describe the
process that was
+ * already destroyed and already counted; applying them would destroy the
healthy
+ * replacement and book a second restart for a single death.
+ */
+ private boolean isSuperseded(long reportedGeneration, RestartReason
reason) {
+ if (reportedGeneration >= generation) {
+ return false;
}
+ LOG.debug("dropping stale {} report for generation {}; current
generation is {}",
+ reason, reportedGeneration, generation);
+ return true;
+ }
+
+ /** Callers hold {@code lock}. */
+ private void markForRestart(RestartReason reason) {
+ restarts.mark(reason);
+ pendingRestart = true;
}
@Override
@@ -185,43 +223,32 @@ public class SharedServerManager implements ServerManager
{
}
@Override
- public void markServerForRestart(long generation) {
+ public long getRestartCount(RestartReason reason) {
+ return restarts.count(reason);
+ }
+
+ /** Another client may already have attributed this crash (OOM/TIMEOUT);
don't overwrite it. */
+ @Override
+ public int handleCrashAndGetExitCode() {
synchronized (lock) {
- if (isSuperseded(generation)) {
- return;
- }
- LOG.debug("Server marked for restart - will restart on next
ensureRunning()");
+ restarts.markIfUnmarked(RestartReason.CRASH);
pendingRestart = true;
}
+ return -1;
}
@Override
public int handleCrashAndGetExitCode(long generation) {
synchronized (lock) {
- if (isSuperseded(generation)) {
+ if (isSuperseded(generation, RestartReason.CRASH)) {
return -1;
}
+ restarts.markIfUnmarked(RestartReason.CRASH);
pendingRestart = true;
}
return -1;
}
- /**
- * A restart kills the shared JVM out from under every sibling still
parsing on it, and
- * {@code ensureRunning} holds {@code lock} for the whole fork, so those
siblings cannot
- * report until after the replacement is up. Their reports describe the
process that was
- * already destroyed; applying them would destroy the healthy replacement
too. Callers
- * hold {@code lock}.
- */
- private boolean isSuperseded(long reportedGeneration) {
- if (reportedGeneration >= generation) {
- return false;
- }
- LOG.debug("dropping stale restart report for generation {}; current
generation is {}",
- reportedGeneration, generation);
- return true;
- }
-
/**
* Increments the count of files processed and marks for restart if limit
reached.
*/
@@ -235,6 +262,9 @@ public class SharedServerManager implements ServerManager {
synchronized (lock) {
LOG.info("Shared server reached max files limit ({}/{}),
marking for restart",
count, maxFilesPerProcess);
+ // A fatal reason already recorded for this same pending
restart outranks a
+ // scheduled recycle: the boundary file may be the one that
killed the worker.
+ restarts.markIfUnmarked(RestartReason.MAX_FILES);
pendingRestart = true;
}
}
@@ -461,7 +491,8 @@ public class SharedServerManager implements ServerManager {
}
} finally {
// An interrupt here must not leave the field pointing at a
SIGKILLed process:
- // startServer() would then try to reap it again and tmpDir
would never be deleted.
+ // ensureRunning would then see process == previous and skip
counting the restart,
+ // startServer() would try to reap it again, and tmpDir would
never be deleted.
process = null;
}
}
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/async/AsyncProcessor.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/async/AsyncProcessor.java
index be61563d12..dbb8336e71 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/async/AsyncProcessor.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/async/AsyncProcessor.java
@@ -50,6 +50,7 @@ import org.apache.tika.pipes.core.PipesClient;
import org.apache.tika.pipes.core.PipesConfig;
import org.apache.tika.pipes.core.PipesException;
import org.apache.tika.pipes.core.PipesResults;
+import org.apache.tika.pipes.core.RestartReason;
import org.apache.tika.pipes.core.ServerManager;
import org.apache.tika.pipes.core.SharedServerManager;
import org.apache.tika.pipes.core.emitter.EmitterManager;
@@ -247,6 +248,16 @@ public class AsyncProcessor implements Closeable {
return fetchEmitTuples.remainingCapacity();
}
+ /** Tuples accepted but not yet picked up by a worker. */
+ public int getQueueDepth() {
+ return fetchEmitTuples.size();
+ }
+
+ /** Restarts of this processor's own forks, summed over all servers, for
{@code reason}. */
+ public long getRestartCount(RestartReason reason) {
+ return serverManagers.stream().mapToLong(m ->
m.getRestartCount(reason)).sum();
+ }
+
/**
* Long-running callers (tika-server) set true so one bad tuple
* (unknown fetcher, init failure) can't halt the shared workers;
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
index 3bcbe0cb0a..a92602242d 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
@@ -162,6 +162,10 @@ public class PipesServer implements AutoCloseable {
* tell the difference between "I crashed" and "my parent went away". */
public static final int PARENT_GONE_EXIT_CODE = 23;
+ /** Idle self-exit (no request within socketTimeoutMillis). Not 0: that
means a
+ * parent-requested SHUT_DOWN. Other codes: {@link
PipesMessageType#getExitCode()}. */
+ public static final int IDLE_EXIT_CODE = 24;
+
private final long heartbeatIntervalMillis;
private final String pipesClientId;
@@ -416,7 +420,7 @@ public class PipesServer implements AutoCloseable {
} catch (Exception ex) {
//swallow
}
- System.exit(0);
+ System.exit(IDLE_EXIT_CODE);
return; // unreachable, but needed for compilation
}
LOG.trace("received message type={}", msg.type());
diff --git
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/RestartCounterTest.java
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/RestartCounterTest.java
new file mode 100644
index 0000000000..0bb633dbe6
--- /dev/null
+++
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/RestartCounterTest.java
@@ -0,0 +1,125 @@
+/*
+ * 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.
+ */
+package org.apache.tika.pipes.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import org.apache.tika.pipes.core.protocol.PipesMessageType;
+import org.apache.tika.pipes.core.server.PipesServer;
+
+public class RestartCounterTest {
+
+ @Test
+ public void testMarkedReasonWins() {
+ RestartCounter c = new RestartCounter();
+ c.mark(RestartReason.MAX_FILES);
+ c.restarted(PipesServer.IDLE_EXIT_CODE);
+ assertEquals(1, c.count(RestartReason.MAX_FILES));
+ assertEquals(0, c.count(RestartReason.IDLE));
+ }
+
+ @Test
+ public void testLastMarkWins() {
+ RestartCounter c = new RestartCounter();
+ c.mark(RestartReason.CRASH);
+ c.mark(RestartReason.OOM);
+ c.restarted(-1);
+ assertEquals(1, c.count(RestartReason.OOM));
+ assertEquals(0, c.count(RestartReason.CRASH));
+ }
+
+ @Test
+ public void testMarkIfUnmarkedKeepsSpecificReason() {
+ RestartCounter c = new RestartCounter();
+ c.mark(RestartReason.OOM);
+ c.markIfUnmarked(RestartReason.CRASH);
+ c.restarted(-1);
+ assertEquals(1, c.count(RestartReason.OOM));
+ c.markIfUnmarked(RestartReason.CRASH);
+ c.restarted(-1);
+ assertEquals(1, c.count(RestartReason.CRASH));
+ }
+
+ @ParameterizedTest
+ @MethodSource("exitCodes")
+ public void testUnmarkedAttributedByExitCode(int exitCode, RestartReason
expected) {
+ RestartCounter c = new RestartCounter();
+ c.restarted(exitCode);
+ for (RestartReason r : RestartReason.values()) {
+ assertEquals(r == expected ? 1 : 0, c.count(r), r.name());
+ }
+ }
+
+ /**
+ * Enumerated from the codes the child can deliberately choose ({@link
PipesMessageType} and
+ * {@link PipesServer}), not from RestartCounter's own branches: a table
derived from the
+ * implementation cannot catch a case the implementation forgot.
+ */
+ static Stream<Arguments> exitCodes() {
+ return Stream.of(
+ Arguments.of(PipesServer.IDLE_EXIT_CODE, RestartReason.IDLE),
+ Arguments.of(PipesMessageType.OOM.getExitCode().getAsInt(),
RestartReason.OOM),
+
Arguments.of(PipesMessageType.TIMEOUT.getExitCode().getAsInt(),
RestartReason.TIMEOUT),
+
Arguments.of(PipesMessageType.UNSPECIFIED_CRASH.getExitCode().getAsInt(),
RestartReason.CRASH),
+ Arguments.of(0, RestartReason.SHUTDOWN),
+ Arguments.of(1, RestartReason.CRASH),
+ Arguments.of(-1, RestartReason.CRASH));
+ }
+
+ @Test
+ public void testFatalReasonOutranksScheduledRecycle() {
+ // The file-limit boundary document may be the one that killed the
worker; a scheduled
+ // recycle must not overwrite the fatal reason recorded for the same
pending restart.
+ RestartCounter c = new RestartCounter();
+ c.mark(RestartReason.OOM);
+ c.markIfUnmarked(RestartReason.MAX_FILES);
+ c.restarted(-1);
+ assertEquals(1, c.count(RestartReason.OOM));
+ assertEquals(0, c.count(RestartReason.MAX_FILES));
+ }
+
+ @Test
+ public void testPendingClearsAfterRestart() {
+ RestartCounter c = new RestartCounter();
+ c.mark(RestartReason.TIMEOUT);
+ c.restarted(-1);
+ c.restarted(PipesServer.IDLE_EXIT_CODE);
+ assertEquals(1, c.count(RestartReason.TIMEOUT));
+ assertEquals(1, c.count(RestartReason.IDLE));
+ }
+
+ /** A mark left over from a start that never produced a process must not
bleed into the next restart. */
+ @Test
+ public void testFirstStartNotCountedAndClearsMark() {
+ RestartCounter c = new RestartCounter();
+ c.mark(RestartReason.CRASH);
+ c.restarted((Process) null);
+ for (RestartReason r : RestartReason.values()) {
+ assertEquals(0, c.count(r), r.name());
+ }
+ c.restarted(PipesServer.IDLE_EXIT_CODE);
+ assertEquals(1, c.count(RestartReason.IDLE));
+ assertEquals(0, c.count(RestartReason.CRASH));
+ }
+}
diff --git
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/ServerManagerMarkContractTest.java
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/ServerManagerMarkContractTest.java
new file mode 100644
index 0000000000..72bd63e7df
--- /dev/null
+++
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/ServerManagerMarkContractTest.java
@@ -0,0 +1,150 @@
+/*
+ * 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.
+ */
+package org.apache.tika.pipes.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.Socket;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * {@link ServerManager} offers two spellings of "recycle this worker" and
every in-repo caller
+ * uses the reason form, so a regression in the no-arg form is invisible to
every other test.
+ * These pin the contract from the <em>caller's</em> side: whichever spelling
a downstream
+ * integration picked up, the worker must actually be marked.
+ */
+public class ServerManagerMarkContractTest {
+
+ /** Overrides only the no-arg form -- what a pre-RestartReason
implementation would have. */
+ private static class NoArgOnly implements ServerManager {
+ private int marks;
+
+ @Override
+ public void markServerForRestart() {
+ marks++;
+ }
+
+ @Override
+ public int getPort() {
+ return -1;
+ }
+
+ @Override
+ public void ensureRunning() {
+ }
+
+ @Override
+ public Socket connect(int socketTimeoutMillis) {
+ return null;
+ }
+
+ @Override
+ public void shutdown() {
+ }
+
+ @Override
+ public boolean isRunning() {
+ return false;
+ }
+
+ @Override
+ public Path getTempDirectory() {
+ return null;
+ }
+
+ @Override
+ public void close() {
+ }
+ }
+
+ private static PipesConfig config() {
+ return new PipesConfig();
+ }
+
+ @Test
+ public void testPerClientHonoursBothSpellings() {
+ PerClientServerManager sm = new PerClientServerManager(config(), null,
0);
+ sm.markServerForRestart();
+ assertTrue(sm.needsRestart(), "no-arg markServerForRestart() must
recycle the worker");
+
+ PerClientServerManager other = new PerClientServerManager(config(),
null, 1);
+ other.markServerForRestart(RestartReason.OOM);
+ assertTrue(other.needsRestart(), "reason form must recycle the
worker");
+ }
+
+ @Test
+ public void testSharedHonoursBothSpellings() {
+ SharedServerManager sm = new SharedServerManager(config(), null, 2);
+ sm.markServerForRestart();
+ assertTrue(sm.needsRestart(), "no-arg markServerForRestart() must
recycle the worker");
+
+ SharedServerManager other = new SharedServerManager(config(), null, 2);
+ other.markServerForRestart(RestartReason.OOM);
+ assertTrue(other.needsRestart(), "reason form must recycle the
worker");
+ }
+
+ @Test
+ public void testReasonFormReachesANoArgOnlyImplementation() {
+ NoArgOnly sm = new NoArgOnly();
+ sm.markServerForRestart(RestartReason.OOM);
+ assertEquals(1, sm.marks, "reason form must fall back to an older
no-arg implementation");
+ }
+
+ @Test
+ public void testDefaultsDoNotRecurse() {
+ // markServerForRestart(reason) defaults to the no-arg form, so the
no-arg form must not
+ // default back to it: an implementation overriding neither would blow
the stack.
+ ServerManager sm = new ServerManager() {
+ @Override
+ public int getPort() {
+ return -1;
+ }
+
+ @Override
+ public void ensureRunning() {
+ }
+
+ @Override
+ public Socket connect(int socketTimeoutMillis) {
+ return null;
+ }
+
+ @Override
+ public void shutdown() {
+ }
+
+ @Override
+ public boolean isRunning() {
+ return false;
+ }
+
+ @Override
+ public Path getTempDirectory() {
+ return null;
+ }
+
+ @Override
+ public void close() {
+ }
+ };
+ sm.markServerForRestart();
+ sm.markServerForRestart(RestartReason.CRASH);
+ }
+}
diff --git
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/SharedServerModeTest.java
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/SharedServerModeTest.java
index 0e5a2d2e23..b7f0e15bc6 100644
---
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/SharedServerModeTest.java
+++
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/SharedServerModeTest.java
@@ -623,6 +623,7 @@ public class SharedServerModeTest {
assertTrue(verifyResult.isSuccess(),
"After concurrent OOM, server should restart and process
new request. Got: " + verifyResult.status());
+ assertRestartsAttributedToOom(pipesParser);
assertOneForkPerDeath(pipesParser, 1);
}
}
@@ -768,6 +769,7 @@ public class SharedServerModeTest {
"Phase 1 had: " + phase1OomCount + " OOMs, " +
phase1SuccessCount + " successes, " +
phase1CrashCount + " crashes");
+ assertRestartsAttributedToOom(pipesParser);
assertOneForkPerDeath(pipesParser, 1);
}
}
@@ -863,6 +865,30 @@ public class SharedServerModeTest {
}
}
+ /**
+ * The shared JVM died once, from OOM. Siblings whose in-flight parses
were killed by that
+ * death report a crash <em>result</em>, which is correct; none of them is
a second process
+ * death, so nothing may be booked as an extra restart.
+ */
+ private void assertRestartsAttributedToOom(PipesParser pipesParser) {
+ long oom = pipesParser.getRestartCount(RestartReason.OOM);
+ long crash = pipesParser.getRestartCount(RestartReason.CRASH);
+ String counts = " (oom=" + oom + ", crash=" + crash + ", timeout=" +
+ pipesParser.getRestartCount(RestartReason.TIMEOUT) + ",
maxFiles=" +
+ pipesParser.getRestartCount(RestartReason.MAX_FILES) + ")";
+ assertTrue(oom >= 1, "the OOM death should be attributed to OOM" +
counts);
+ assertEquals(0, crash,
+ "no restart may be attributed to CRASH: the only process death
was the OOM" + counts);
+ // Reason tags alone cannot distinguish "the phantom restart is gone"
from "the phantom
+ // restart is now labelled OOM". Total restarts is the honest
observable: one death, one
+ // restart, whatever it is called.
+ long total = 0;
+ for (RestartReason r : RestartReason.values()) {
+ total += pipesParser.getRestartCount(r);
+ }
+ assertEquals(1, total, "exactly one restart may follow a single
process death" + counts);
+ }
+
private Path setupInputDir(Path tmp) throws Exception {
Path inputDir = tmp.resolve("input");
Files.createDirectories(inputDir);
diff --git a/tika-server/tika-server-core/pom.xml
b/tika-server/tika-server-core/pom.xml
index b030d37f70..fd60838c42 100644
--- a/tika-server/tika-server-core/pom.xml
+++ b/tika-server/tika-server-core/pom.xml
@@ -88,6 +88,18 @@
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
</dependency>
+ <dependency>
+ <groupId>jakarta.annotation</groupId>
+ <artifactId>jakarta.annotation-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>io.micrometer</groupId>
+ <artifactId>micrometer-core</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>io.micrometer</groupId>
+ <artifactId>micrometer-registry-prometheus</artifactId>
+ </dependency>
<dependency>
<groupId>org.apache.cxf</groupId>
<artifactId>cxf-rt-rs-security-cors</artifactId>
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/ServerStatus.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/ServerStatus.java
index 6219a72839..14329693b4 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/ServerStatus.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/ServerStatus.java
@@ -69,6 +69,11 @@ public class ServerStatus {
return new HashMap<>(tasks);
}
+ /** Count only; avoids copying the task map (and its filenames) on every
metrics scrape. */
+ public synchronized int getNumTasks() {
+ return tasks.size();
+ }
+
/**
* Returns the total number of tasks started since server startup.
*/
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerCli.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerCli.java
index 6e5da93fd5..49dab1b970 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerCli.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerCli.java
@@ -39,6 +39,9 @@ public class TikaServerCli {
options.addOption("?", "help", false, "this help message");
options.addOption("c", "config", true, "tika-config file");
options.addOption("i", "id", true, "id for this server, written to the
startup log");
+ options.addOption(null, "metricsPort", true,
+ "serve Prometheus metrics on this port (9404 by convention); "
+ + "unset means metrics are off\n");
return options;
}
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
index eb8912eb85..b99a234406 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
@@ -74,6 +74,7 @@ public class TikaServerConfig {
private ArrayList<String> endpoints = new ArrayList<>();
private TlsConfig tlsConfig = new TlsConfig();
+ private Integer metricsPort;
/**
* Config with only the defaults
@@ -109,6 +110,16 @@ public class TikaServerConfig {
settings.add("id");
}
+ if (commandLine.hasOption("metricsPort")) {
+ String value = commandLine.getOptionValue("metricsPort");
+ try {
+ config.setMetricsPort(Integer.parseInt(value));
+ } catch (NumberFormatException e) {
+ throw new TikaConfigException("--metricsPort must be an
integer, got '" + value + "'");
+ }
+ settings.add("metricsPort");
+ }
+
config.validateConsistency(settings);
return config;
}
@@ -180,6 +191,15 @@ public class TikaServerConfig {
}
}
tlsConfig.checkInitialization();
+ if (metricsPort != null) {
+ if (metricsPort < 0 || metricsPort > 65535) {
+ throw new TikaConfigException("metricsPort must be 0-65535,
got " + metricsPort);
+ }
+ if (metricsPort != 0 && metricsPort == port) {
+ throw new TikaConfigException("metricsPort (" + metricsPort
+ + ") must differ from the server port");
+ }
+ }
}
public String getHost() {
@@ -269,6 +289,15 @@ public class TikaServerConfig {
this.tlsConfig = tlsConfig;
}
+ /** Prometheus scrape port; null means metrics are off. The listener binds
to {@link #getHost()}. */
+ public Integer getMetricsPort() {
+ return metricsPort;
+ }
+
+ public void setMetricsPort(Integer metricsPort) {
+ this.metricsPort = metricsPort;
+ }
+
public ArrayList<String> getEndpoints() {
return endpoints;
}
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
index 0e99ec7c71..e83b8a67cb 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
@@ -70,6 +70,9 @@ import org.apache.tika.pipes.core.PipesConfig;
import org.apache.tika.pipes.core.PipesParser;
import org.apache.tika.pipes.core.config.ConfigMerger;
import org.apache.tika.pipes.core.config.ConfigOverrides;
+import org.apache.tika.server.core.metrics.MetricsServer;
+import org.apache.tika.server.core.metrics.TikaMetricsFilter;
+import org.apache.tika.server.core.metrics.TikaServerMetrics;
import org.apache.tika.server.core.resource.AsyncResource;
import org.apache.tika.server.core.resource.DetectorResource;
import org.apache.tika.server.core.resource.LanguageResource;
@@ -118,6 +121,9 @@ public class TikaServerProcess {
options.addOption("p", "port", true, "listen port");
options.addOption("c", "config", true, "Tika Configuration xml file to
override default config with.");
options.addOption("i", "id", true, "id for this server, written to the
startup log");
+ options.addOption(null, "metricsPort", true,
+ "serve Prometheus metrics on this port (9404 by convention); "
+ + "unset means metrics are off\n");
options.addOption("?", "help", false, "this help message");
return options;
}
@@ -137,8 +143,8 @@ public class TikaServerProcess {
LOG.debug("forked config: {}", tikaServerConfig);
ServerDetails serverDetails = initServer(tikaServerConfig);
- startServer(serverDetails);
registerOrderedShutdown(serverDetails);
+ startServer(serverDetails);
} catch (Exception e) {
LOG.error("Can't start: ", e);
@@ -157,6 +163,10 @@ public class TikaServerProcess {
}
private static void startServer(ServerDetails serverDetails) {
+ // Metrics first: a scrape-port clash must not leave the parse port
briefly open.
+ if (serverDetails.metricsServer != null) {
+ startMetricsServer(serverDetails);
+ }
try {
//start the server
serverDetails.server = serverDetails.sf.create();
@@ -170,6 +180,21 @@ public class TikaServerProcess {
LOG.info("Started Apache Tika server {} at {}",
serverDetails.serverId, serverDetails.url);
}
+ private static void startMetricsServer(ServerDetails serverDetails) {
+ try {
+ serverDetails.metricsServer.start();
+ } catch (Exception e) {
+ LOG.warn("exception starting metrics server", e);
+ if (isBindException(e)) {
+ System.exit(BIND_EXCEPTION);
+ }
+ System.exit(DO_NOT_RESTART_EXIT_VALUE);
+ }
+ LOG.info("Started metrics listener (plain HTTP) on http://{}:{}{}",
+ serverDetails.metricsServer.getHost(),
serverDetails.metricsServer.getPort(),
+ MetricsServer.PATH);
+ }
+
/**
* One ordered shutdown: stop the HTTP endpoint first so no new request
can arrive, then
* tear down the pipes workers and delete their temp directories. Replaces
the previously
@@ -185,9 +210,19 @@ public class TikaServerProcess {
LOG.warn("Error stopping HTTP server", e);
}
}
+ if (serverDetails.metricsServer != null) {
+ try {
+ serverDetails.metricsServer.close();
+ } catch (Exception e) {
+ LOG.warn("Error stopping metrics server", e);
+ }
+ }
if (serverDetails.pipesParsingHelper != null) {
serverDetails.pipesParsingHelper.shutdown();
}
+ if (serverDetails.metrics != null) {
+ serverDetails.metrics.close();
+ }
}));
}
@@ -249,6 +284,24 @@ public class TikaServerProcess {
List<Object> providers = new ArrayList<>();
loadAllProviders(tikaServerConfig, serverStatus, tikaResource,
resourceProviders, providers);
+ TikaServerMetrics metrics = null;
+ MetricsServer metricsServer = null;
+ if (tikaServerConfig.getMetricsPort() != null) {
+ metrics = new TikaServerMetrics();
+ metrics.bindJvm();
+ metrics.bindServerStatus(serverStatus);
+ if (pipesParsingHelper != null) {
+ metrics.bindSyncPool(pipesParsingHelper.getPipesParser());
+ }
+ AsyncResource asyncResource = findAsyncResource(resourceProviders);
+ if (asyncResource != null) {
+ metrics.bindAsyncPool(asyncResource.getAsyncProcessor());
+ }
+ providers.add(new TikaMetricsFilter(metrics,
Set.copyOf(VALID_ENDPOINTS)));
+ metricsServer = new MetricsServer(tikaServerConfig.getHost(),
+ tikaServerConfig.getMetricsPort(), metrics);
+ }
+
sf.setResourceProviders(resourceProviders);
sf.setProviders(providers);
@@ -290,9 +343,21 @@ public class TikaServerProcess {
details.serverId = tikaServerConfig.getId();
details.serverStatus = serverStatus;
details.pipesParsingHelper = pipesParsingHelper;
+ details.metrics = metrics;
+ details.metricsServer = metricsServer;
return details;
}
+ private static AsyncResource findAsyncResource(List<ResourceProvider>
resourceProviders) {
+ for (ResourceProvider p : resourceProviders) {
+ if (p instanceof SingletonResourceProvider s
+ && s.getInstance(null) instanceof AsyncResource
asyncResource) {
+ return asyncResource;
+ }
+ }
+ return null;
+ }
+
private static TLSServerParameters getTlsParams(TlsConfig tlsConfig)
throws GeneralSecurityException, IOException, TikaConfigException {
// Also checked in TlsConfig.checkInitialization() at config-load
time; kept here too
@@ -753,5 +818,7 @@ public class TikaServerProcess {
String url;
ServerStatus serverStatus;
PipesParsingHelper pipesParsingHelper;
+ TikaServerMetrics metrics;
+ MetricsServer metricsServer;
}
}
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/MetricsServer.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/MetricsServer.java
new file mode 100644
index 0000000000..2ab2c1ed1d
--- /dev/null
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/MetricsServer.java
@@ -0,0 +1,113 @@
+/*
+ * 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.
+ */
+package org.apache.tika.server.core.metrics;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.eclipse.jetty.server.ConnectionLimit;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.HttpConfiguration;
+import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Response;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.util.Callback;
+import org.eclipse.jetty.util.thread.QueuedThreadPool;
+
+/**
+ * Minimal standalone Jetty answering only {@code GET /metrics}. Separate from
the CXF
+ * server so the parse endpoints are unreachable here and a slow parse cannot
starve a scrape.
+ */
+public final class MetricsServer implements AutoCloseable {
+
+ public static final String PATH = "/metrics";
+ static final String CONTENT_TYPE = "text/plain; version=0.0.4;
charset=utf-8";
+ private static final int MAX_THREADS = 4;
+ // Unauthenticated port: cap what idle sockets can take from the process
FD budget.
+ private static final int MAX_CONNECTIONS = 64;
+ private static final long IDLE_TIMEOUT_MS = 10_000;
+
+ private final Server server;
+ private final ServerConnector connector;
+
+ public MetricsServer(String host, int port, TikaServerMetrics metrics) {
+ QueuedThreadPool pool = new QueuedThreadPool(MAX_THREADS, 1);
+ pool.setName("tika-metrics");
+ server = new Server(pool);
+ HttpConfiguration httpConfig = new HttpConfiguration();
+ httpConfig.setSendServerVersion(false);
+ connector = new ServerConnector(server, new
HttpConnectionFactory(httpConfig));
+ connector.setHost(host);
+ connector.setPort(port);
+ connector.setIdleTimeout(IDLE_TIMEOUT_MS);
+ server.addConnector(connector);
+ server.addBean(new ConnectionLimit(MAX_CONNECTIONS, connector));
+ server.setHandler(new ScrapeHandler(metrics));
+ }
+
+ public void start() throws Exception {
+ server.start();
+ }
+
+ public String getHost() {
+ return connector.getHost();
+ }
+
+ /** The bound port; meaningful after {@link #start()} (port 0 picks a free
one). */
+ public int getPort() {
+ return connector.getLocalPort();
+ }
+
+ @Override
+ public void close() throws Exception {
+ server.stop();
+ }
+
+ private static final class ScrapeHandler extends Handler.Abstract {
+
+ private final TikaServerMetrics metrics;
+
+ private ScrapeHandler(TikaServerMetrics metrics) {
+ this.metrics = metrics;
+ }
+
+ @Override
+ public boolean handle(Request request, Response response, Callback
callback) {
+ String path = Request.getPathInContext(request);
+ if (!PATH.equals(path) && !(PATH + "/").equals(path)) {
+ Response.writeError(request, response, callback,
HttpStatus.NOT_FOUND_404);
+ return true;
+ }
+ boolean head = HttpMethod.HEAD.is(request.getMethod());
+ if (!head && !HttpMethod.GET.is(request.getMethod())) {
+ Response.writeError(request, response, callback,
HttpStatus.METHOD_NOT_ALLOWED_405);
+ return true;
+ }
+ byte[] body = metrics.scrape().getBytes(StandardCharsets.UTF_8);
+ response.setStatus(HttpStatus.OK_200);
+ response.getHeaders().put(HttpHeader.CONTENT_TYPE, CONTENT_TYPE);
+ response.getHeaders().put(HttpHeader.CONTENT_LENGTH, body.length);
+ response.write(true, head ? null : ByteBuffer.wrap(body),
callback);
+ return true;
+ }
+ }
+}
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaMetricsFilter.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaMetricsFilter.java
new file mode 100644
index 0000000000..c288588fee
--- /dev/null
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaMetricsFilter.java
@@ -0,0 +1,95 @@
+/*
+ * 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.
+ */
+package org.apache.tika.server.core.metrics;
+
+import java.util.List;
+import java.util.Set;
+
+import jakarta.annotation.Priority;
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ContainerRequestFilter;
+import jakarta.ws.rs.container.ContainerResponseContext;
+import jakarta.ws.rs.container.ContainerResponseFilter;
+import jakarta.ws.rs.container.PreMatching;
+import jakarta.ws.rs.ext.Provider;
+
+/**
+ * Records {@code tika_server_requests} and the request-size summary for every
request on
+ * the parse-side listener. Timing starts pre-matching so unmatched (404) and
aborted
+ * requests are counted too.
+ * <p>
+ * Not counted: an exception no {@code ExceptionMapper} handles. CXF answers
those from
+ * its fault chain, which does not run response filters.
+ */
+@Provider
+@PreMatching
+// Ahead of CXF's CORS filter (999) so a preflight abort is timed, not
recorded as 0.
+@Priority(100)
+public class TikaMetricsFilter implements ContainerRequestFilter,
ContainerResponseFilter {
+
+ static final String START_NANOS = TikaMetricsFilter.class.getName() +
".start";
+ static final String RECORDED = TikaMetricsFilter.class.getName() +
".recorded";
+ static final String UNMATCHED = "unmatched";
+ static final String OTHER = "other";
+
+ private final TikaServerMetrics metrics;
+ private final Set<String> knownEndpoints;
+
+ public TikaMetricsFilter(TikaServerMetrics metrics, Set<String>
knownEndpoints) {
+ this.metrics = metrics;
+ this.knownEndpoints = knownEndpoints;
+ }
+
+ @Override
+ public void filter(ContainerRequestContext requestContext) {
+ requestContext.setProperty(START_NANOS, System.nanoTime());
+ }
+
+ @Override
+ public void filter(ContainerRequestContext requestContext,
+ ContainerResponseContext responseContext) {
+ // CXF re-runs response filters when a mapped exception is thrown
mid-write.
+ if (requestContext.getProperty(RECORDED) != null) {
+ return;
+ }
+ requestContext.setProperty(RECORDED, Boolean.TRUE);
+ String endpoint = endpoint(requestContext);
+ Object start = requestContext.getProperty(START_NANOS);
+ long nanos = start instanceof Long s ? System.nanoTime() - s : 0L;
+ metrics.recordRequest(endpoint, requestContext.getMethod(),
responseContext.getStatus(),
+ nanos);
+ int length = requestContext.getLength();
+ if (length >= 0) {
+ metrics.recordRequestSize(endpoint, length);
+ }
+ }
+
+ /**
+ * First path segment when it names a known endpoint; otherwise {@code
other} for a
+ * matched resource and {@code unmatched} for a request answered before
routing.
+ */
+ private String endpoint(ContainerRequestContext requestContext) {
+ String path = requestContext.getUriInfo().getPath();
+ int slash = path.indexOf('/');
+ String root = slash < 0 ? path : path.substring(0, slash);
+ if (knownEndpoints.contains(root)) {
+ return root;
+ }
+ List<Object> matched =
requestContext.getUriInfo().getMatchedResources();
+ return matched == null || matched.isEmpty() ? UNMATCHED : OTHER;
+ }
+}
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaServerMetrics.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaServerMetrics.java
new file mode 100644
index 0000000000..bd98312302
--- /dev/null
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaServerMetrics.java
@@ -0,0 +1,204 @@
+/*
+ * 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.
+ */
+package org.apache.tika.server.core.metrics;
+
+import java.time.Duration;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.function.ToLongFunction;
+
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.DistributionSummary;
+import io.micrometer.core.instrument.FunctionCounter;
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics;
+import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics;
+import io.micrometer.core.instrument.binder.system.FileDescriptorMetrics;
+import io.micrometer.core.instrument.binder.system.UptimeMetrics;
+import io.micrometer.prometheusmetrics.PrometheusConfig;
+import io.micrometer.prometheusmetrics.PrometheusMeterRegistry;
+
+import org.apache.tika.pipes.core.PipesParser;
+import org.apache.tika.pipes.core.RestartReason;
+import org.apache.tika.pipes.core.async.AsyncProcessor;
+import org.apache.tika.server.core.ServerStatus;
+
+/**
+ * Owns the process's {@link PrometheusMeterRegistry} and every meter recorded
against it.
+ * Every tag value comes from a bounded set; nothing request-derived becomes a
tag.
+ */
+public final class TikaServerMetrics implements AutoCloseable {
+
+ /**
+ * One bucket set for every duration timer. Twelve explicit boundaries
instead of
+ * Micrometer's percentile histogram, which would emit roughly 70 per
series.
+ */
+ public static final Duration[] DURATION_SLOS = {
+ Duration.ofMillis(10), Duration.ofMillis(50),
Duration.ofMillis(100),
+ Duration.ofMillis(250), Duration.ofMillis(500),
Duration.ofSeconds(1),
+ Duration.ofSeconds(2), Duration.ofSeconds(5),
Duration.ofSeconds(10),
+ Duration.ofSeconds(30), Duration.ofSeconds(60),
Duration.ofSeconds(120)
+ };
+
+ /** Bucket boundaries for byte-size summaries: 1KB .. 1GB by decades. */
+ private static final double[] SIZE_SLOS = {
+ 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000,
1_000_000_000
+ };
+
+ /**
+ * Jetty passes any RFC-token method through to the filter, so the raw
verb would let a
+ * client mint meters without limit. Anything outside this set is tagged
{@code other}.
+ */
+ private static final Set<String> KNOWN_METHODS =
+ Set.of("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH",
"TRACE", "CONNECT");
+
+ static final String REQUESTS = "tika_server_requests";
+ static final String REQUEST_SIZE = "tika_server_request_size_bytes";
+ static final String REJECTED = "tika_server_rejected_total";
+ static final String TASKS_ACTIVE = "tika_server_tasks_active";
+ static final String PIPES_WORKERS = "tika_pipes_workers";
+ static final String PIPES_RESTARTS = "tika_pipes_worker_restarts_total";
+ static final String PIPES_QUEUE_DEPTH = "tika_pipes_queue_depth";
+
+ static final String TAG_ENDPOINT = "endpoint";
+ static final String TAG_METHOD = "method";
+ static final String TAG_STATUS = "status";
+ static final String TAG_REASON = "reason";
+ static final String TAG_POOL = "pool";
+ static final String TAG_STATE = "state";
+
+ /** Worker pools a tika-server can run at once; each has its own forks. */
+ static final String POOL_SYNC = "sync";
+ static final String POOL_ASYNC = "async";
+
+ private final PrometheusMeterRegistry registry = new
PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
+
+ MeterRegistry getRegistry() {
+ return registry;
+ }
+
+ /** Prometheus text exposition (format 0.0.4). */
+ public String scrape() {
+ return registry.scrape();
+ }
+
+ /** No GC/CPU binders: this JVM does not parse, so they would suggest
headroom that is not there. */
+ public void bindJvm() {
+ new JvmMemoryMetrics().bindTo(registry);
+ new JvmThreadMetrics().bindTo(registry);
+ new UptimeMetrics().bindTo(registry);
+ new FileDescriptorMetrics().bindTo(registry);
+ }
+
+ public void bindServerStatus(ServerStatus serverStatus) {
+ Gauge.builder(TASKS_ACTIVE, serverStatus, ServerStatus::getNumTasks)
+ .description("Sync parse/detect tasks currently running in
this server")
+ .register(registry);
+ }
+
+ /** The sync endpoints' forks: restart counters plus busy/idle slots. */
+ public void bindSyncPool(PipesParser pipesParser) {
+ bindRestarts(POOL_SYNC, pipesParser::getRestartCount);
+ Gauge.builder(PIPES_WORKERS, pipesParser, p -> p.getNumClients() -
p.getIdleClientCount())
+ .tag(TAG_POOL, POOL_SYNC)
+ .tag(TAG_STATE, "busy")
+ .description("Pipes worker slots by state")
+ .register(registry);
+ Gauge.builder(PIPES_WORKERS, pipesParser,
PipesParser::getIdleClientCount)
+ .tag(TAG_POOL, POOL_SYNC)
+ .tag(TAG_STATE, "idle")
+ .description("Pipes worker slots by state")
+ .register(registry);
+ }
+
+ /** {@code /async}'s own forks: restart counters plus queue depth. */
+ public void bindAsyncPool(AsyncProcessor asyncProcessor) {
+ bindRestarts(POOL_ASYNC, asyncProcessor::getRestartCount);
+ Gauge.builder(PIPES_QUEUE_DEPTH, asyncProcessor,
AsyncProcessor::getQueueDepth)
+ .tag(TAG_POOL, POOL_ASYNC)
+ .description("Tuples waiting in the /async queue")
+ .register(registry);
+ }
+
+ private void bindRestarts(String pool, ToLongFunction<RestartReason>
restartCount) {
+ for (RestartReason reason : RestartReason.values()) {
+ FunctionCounter.builder(PIPES_RESTARTS, reason,
restartCount::applyAsLong)
+ .tag(TAG_POOL, pool)
+ .tag(TAG_REASON, reason.name().toLowerCase(Locale.ROOT))
+ .description("Forked pipes worker restarts by reason")
+ .register(registry);
+ }
+ }
+
+ void recordRequest(String endpoint, String method, int status, long nanos)
{
+ Timer.builder(REQUESTS)
+ .tag(TAG_ENDPOINT, endpoint)
+ .tag(TAG_METHOD, methodTag(method))
+ .tag(TAG_STATUS, statusClass(status))
+ .description("HTTP requests handled by the parse-side
listener")
+ .serviceLevelObjectives(DURATION_SLOS)
+ .register(registry)
+ .record(nanos, TimeUnit.NANOSECONDS);
+ String rejected = rejectedReason(status);
+ if (rejected != null) {
+ Counter.builder(REJECTED)
+ .tag(TAG_REASON, rejected)
+ .description("Requests refused for capacity reasons")
+ .register(registry)
+ .increment();
+ }
+ }
+
+ void recordRequestSize(String endpoint, long bytes) {
+ DistributionSummary.builder(REQUEST_SIZE)
+ .tag(TAG_ENDPOINT, endpoint)
+ .baseUnit("bytes")
+ .description("Request body bytes (Content-Length)")
+ .serviceLevelObjectives(SIZE_SLOS)
+ .register(registry)
+ .record(bytes);
+ }
+
+ static String methodTag(String method) {
+ return method != null && KNOWN_METHODS.contains(method) ? method :
"other";
+ }
+
+ static String statusClass(int status) {
+ if (status < 200 || status >= 600) {
+ return "other";
+ }
+ return (status / 100) + "xx";
+ }
+
+ /** By status, as tika-server already maps them: 429 capacity, 503 fork
OOM/timeout/crash, 413 body limit. */
+ static String rejectedReason(int status) {
+ return switch (status) {
+ case 429 -> "busy_429";
+ case 413 -> "payload_413";
+ case 503 -> "crash_503";
+ default -> null;
+ };
+ }
+
+ @Override
+ public void close() {
+ registry.close();
+ }
+}
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/AsyncResource.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/AsyncResource.java
index 1244f5ba31..a5de82d0e6 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/AsyncResource.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/AsyncResource.java
@@ -81,6 +81,10 @@ public class AsyncResource {
this.maxQueuePauseMillis = maxQueuePauseMillis;
}
+ public AsyncProcessor getAsyncProcessor() {
+ return asyncProcessor;
+ }
+
public ArrayBlockingQueue<FetchEmitTuple> getFetchEmitQueue(int queueSize)
{
this.queue = new ArrayBlockingQueue<>(queueSize);
return queue;
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java
index 0f044ead39..6f884c48e7 100644
---
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java
@@ -18,6 +18,7 @@ package org.apache.tika.server.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -31,6 +32,8 @@ import org.apache.commons.cli.DefaultParser;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.Options;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
import org.apache.tika.TikaTest;
import org.apache.tika.exception.TikaConfigException;
@@ -238,4 +241,57 @@ public class TikaServerConfigTest extends TikaTest {
TikaServerConfig config = TikaServerConfig.load(commandLine);
assertTrue(config.getTlsConfig().isClientAuthenticationRequired());
}
+ private static Options metricsOptions() {
+ return new Options()
+
.addOption(Option.builder("c").longOpt("config").hasArg().get())
+ .addOption(Option.builder("p").longOpt("port").hasArg().get())
+
.addOption(Option.builder().longOpt("metricsPort").hasArg().get());
+ }
+
+ @Test
+ public void testMetricsOffByDefault() throws Exception {
+ TikaServerConfig config = TikaServerConfig.load(
+ new DefaultParser().parse(metricsOptions(), new String[]{}));
+ assertNull(config.getMetricsPort());
+ }
+
+ @Test
+ public void testMetricsFromJson() throws Exception {
+ Path path = getConfigPath(getClass(),
"tika-config-server-metrics.json");
+ TikaServerConfig config = TikaServerConfig.load(new
DefaultParser().parse(metricsOptions(),
+ new String[]{"-c",
ProcessUtils.escapeCommandLine(path.toAbsolutePath().toString())}));
+ assertEquals(9404, config.getMetricsPort());
+ }
+
+ @Test
+ public void testMetricsPortFromCommandLine() throws Exception {
+ TikaServerConfig config = TikaServerConfig.load(new
DefaultParser().parse(metricsOptions(),
+ new String[]{"-p", "9998", "--metricsPort", "9500"}));
+ assertEquals(9500, config.getMetricsPort());
+ }
+
+ @ParameterizedTest
+ @CsvSource({"abc, --metricsPort", "65536, 0-65535", "-1, 0-65535"})
+ public void testMetricsPortRejected(String value, String expectedMessage)
throws Exception {
+ CommandLine commandLine = new DefaultParser().parse(metricsOptions(),
+ new String[]{"--metricsPort", value});
+ TikaConfigException ex = assertThrows(TikaConfigException.class,
+ () -> TikaServerConfig.load(commandLine));
+ assertContains(expectedMessage, ex.getMessage());
+ }
+
+ @Test
+ public void testMetricsPortZeroIsEphemeral() throws Exception {
+ assertEquals(0, TikaServerConfig.load(new
DefaultParser().parse(metricsOptions(),
+ new String[]{"--metricsPort", "0"})).getMetricsPort());
+ }
+
+ @Test
+ public void testMetricsPortMustDifferFromServerPort() throws Exception {
+ CommandLine commandLine = new DefaultParser().parse(metricsOptions(),
+ new String[]{"-p", "9998", "--metricsPort", "9998"});
+ TikaConfigException ex = assertThrows(TikaConfigException.class,
+ () -> TikaServerConfig.load(commandLine));
+ assertContains("metricsPort", ex.getMessage());
+ }
}
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerMetricsIntegrationTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerMetricsIntegrationTest.java
new file mode 100644
index 0000000000..6f71ca4a01
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerMetricsIntegrationTest.java
@@ -0,0 +1,247 @@
+/*
+ * 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.
+ */
+package org.apache.tika.server.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Paths;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import jakarta.ws.rs.core.Response;
+import org.apache.cxf.jaxrs.client.WebClient;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import org.apache.tika.server.core.metrics.MetricsServer;
+import org.apache.tika.server.core.metrics.TikaServerMetrics;
+import org.apache.tika.utils.ProcessUtils;
+
+/**
+ * Runs the real server process with {@code --metricsPort} and checks the
scrape output
+ * and the port isolation both ways.
+ */
+public class TikaServerMetricsIntegrationTest extends IntegrationTestBase {
+
+ private final int metricsPort = TestPortAllocator.findFreePort();
+ private final String metricsEndPoint = "http://localhost:" + metricsPort;
+
+ @Test
+ @Timeout(120)
+ public void testScrapeAfterParsesAndWorkerRestart() throws Exception {
+ startProcess(new String[]{"-config",
getConfig("tika-config-server-basic.json"),
+ "--metricsPort", String.valueOf(metricsPort)});
+ awaitServerStartup();
+
+ assertEquals(200, rmeta(TEST_HELLO_WORLD).getStatus());
+ assertEquals(503, rmeta(TEST_OOM).getStatus());
+ // The OOM'd worker is restarted on its next use.
+ assertEquals(200, rmeta(TEST_HELLO_WORLD).getStatus());
+ assertEquals(404, WebClient.create(endPoint +
"/no-such-path").get().getStatus());
+
+ HttpResponse<String> scrape = get(metricsEndPoint +
MetricsServer.PATH);
+ assertEquals(200, scrape.statusCode());
+ assertTrue(scrape.headers().firstValue("Content-Type").orElse("")
+ .startsWith("text/plain; version=0.0.4"));
+ String body = scrape.body();
+
+ assertSample(body, "tika_server_requests_seconds_count",
+ "endpoint=\"rmeta\",method=\"PUT\",status=\"2xx\"", 2.0);
+ assertSample(body, "tika_server_requests_seconds_count",
+ "endpoint=\"rmeta\",method=\"PUT\",status=\"5xx\"", 1.0);
+ assertSample(body, "tika_server_requests_seconds_count",
+ "endpoint=\"unmatched\",method=\"GET\",status=\"4xx\"", 1.0);
+ assertSample(body, "tika_server_rejected_total",
"reason=\"crash_503\"", 1.0);
+ assertSample(body, "tika_pipes_worker_restarts_total",
+ "pool=\"sync\",reason=\"oom\"", 1.0);
+ assertSample(body, "tika_pipes_workers",
"pool=\"sync\",state=\"idle\"", 2.0);
+ assertSample(body, "tika_pipes_workers",
"pool=\"sync\",state=\"busy\"", 0.0);
+ assertSample(body, "tika_server_tasks_active", "", 0.0);
+ assertTrue(body.contains("jvm_memory_used_bytes"), body);
+
assertTrue(body.contains("tika_server_request_size_bytes_count{endpoint=\"rmeta\"}"),
body);
+
+ // Isolation both ways.
+ assertEquals(404, WebClient.create(endPoint +
MetricsServer.PATH).get().getStatus());
+ assertEquals(404, WebClient.create(metricsEndPoint + RMETA_PATH)
+ .accept("application/json")
+
.put(ClassLoader.getSystemResourceAsStream(TEST_HELLO_WORLD)).getStatus());
+ }
+
+ @Test
+ @Timeout(120)
+ public void testOffByDefault() throws Exception {
+ startProcess(new String[]{"-config",
getConfig("tika-config-server-basic.json")});
+ awaitServerStartup();
+ assertEquals(200, rmeta(TEST_HELLO_WORLD).getStatus());
+ assertEquals(404, WebClient.create(endPoint +
MetricsServer.PATH).get().getStatus());
+ // The gate is the port: nothing may be listening on the one the
metrics config names.
+ assertThrows(IOException.class, () -> get(metricsEndPoint +
MetricsServer.PATH),
+ "a scrape listener came up with no metrics port configured");
+ }
+
+ /**
+ * /async forks its own workers, separate from the sync pool's. Without a
pool label and
+ * a second binding, a crash in an async worker is counted nowhere.
+ */
+ @Test
+ @Timeout(240)
+ public void testBothWorkerPoolsAreCounted() throws Exception {
+ startProcess(new String[]{"-config",
getConfig("tika-config-server-async-metrics.json"),
+ "--metricsPort", String.valueOf(metricsPort)});
+ awaitServerStartup();
+ assertEquals(200, rmeta(TEST_HELLO_WORLD).getStatus());
+
+ String body = get(metricsEndPoint + MetricsServer.PATH).body();
+ assertSample(body, "tika_pipes_worker_restarts_total",
"pool=\"sync\",reason=\"oom\"", 0.0);
+ assertSample(body, "tika_pipes_worker_restarts_total",
"pool=\"async\",reason=\"oom\"", 0.0);
+ assertSample(body, "tika_pipes_queue_depth", "pool=\"async\"", 0.0);
+ }
+
+ /** Routine restarts (max files, idle exit 24) must not be counted as
crashes. */
+ @Test
+ @Timeout(240)
+ public void testRoutineRestartReasons() throws Exception {
+ startProcess(new String[]{"-config",
getConfig("tika-config-server-metrics-restarts.json"),
+ "--metricsPort", String.valueOf(metricsPort)});
+ awaitServerStartup();
+ for (int i = 0; i < 3; i++) {
+ assertEquals(200, rmeta(TEST_HELLO_WORLD).getStatus());
+ }
+ // Past the idle socket timeout the fork exits 24 and is restarted by
the next request.
+ String body = awaitSample("tika_pipes_worker_restarts_total",
"pool=\"sync\",reason=\"idle\"", 1.0);
+ assertSample(body, "tika_pipes_worker_restarts_total",
"pool=\"sync\",reason=\"max_files\"", 1.0);
+ assertSample(body, "tika_pipes_worker_restarts_total",
"pool=\"sync\",reason=\"crash\"", 0.0);
+ }
+
+ /** Timeout and crash are attributed per client; a 503 for either is a
crash_503 rejection. */
+ @Test
+ @Timeout(240)
+ public void testTimeoutAndCrashReasons() throws Exception {
+ startProcess(new String[]{"-config",
getConfig("tika-config-server-metrics-timeout.json"),
+ "--metricsPort", String.valueOf(metricsPort)});
+ awaitServerStartup();
+ assertEquals(503, rmeta(TEST_HEAVY_HANG).getStatus());
+ assertEquals(200, rmeta(TEST_HELLO_WORLD).getStatus());
+ assertEquals(503, rmeta(TEST_SYSTEM_EXIT).getStatus());
+ assertEquals(200, rmeta(TEST_HELLO_WORLD).getStatus());
+
+ String body = get(metricsEndPoint + MetricsServer.PATH).body();
+ assertSample(body, "tika_pipes_worker_restarts_total",
"pool=\"sync\",reason=\"timeout\"", 1.0);
+ assertSample(body, "tika_pipes_worker_restarts_total",
"pool=\"sync\",reason=\"crash\"", 1.0);
+ assertSample(body, "tika_pipes_worker_restarts_total",
"pool=\"sync\",reason=\"oom\"", 0.0);
+ assertSample(body, "tika_server_rejected_total",
"reason=\"crash_503\"", 2.0);
+ }
+
+ /** Shared server: the client that saw the OOM marks it; the restarter
must not overwrite it with crash. */
+ @Test
+ @Timeout(240)
+ public void testSharedServerOomReason() throws Exception {
+ startProcess(new String[]{"-config",
getConfig("tika-config-server-metrics-shared.json"),
+ "--metricsPort", String.valueOf(metricsPort)});
+ awaitServerStartup();
+ assertEquals(503, rmeta(TEST_OOM).getStatus());
+ assertEquals(200, rmeta(TEST_HELLO_WORLD).getStatus());
+
+ String body = get(metricsEndPoint + MetricsServer.PATH).body();
+ assertSample(body, "tika_pipes_worker_restarts_total",
"pool=\"sync\",reason=\"oom\"", 1.0);
+ assertSample(body, "tika_pipes_worker_restarts_total",
"pool=\"sync\",reason=\"crash\"", 0.0);
+ }
+
+ /** Polls (parse + scrape) until the sample reaches {@code expected};
returns the last body. */
+ private String awaitSample(String name, String labels, double expected)
throws Exception {
+ long deadline = System.currentTimeMillis() + 30_000;
+ String body;
+ do {
+ // Each parse resets the fork's idle clock, so sleep past the
config's socketTimeoutMillis.
+ Thread.sleep(3000);
+ assertEquals(200, rmeta(TEST_HELLO_WORLD).getStatus());
+ body = get(metricsEndPoint + MetricsServer.PATH).body();
+ if (sample(body, name, labels) == expected) {
+ return body;
+ }
+ } while (System.currentTimeMillis() < deadline);
+ throw new AssertionError("timed out waiting for " + name + "{" +
labels + "}=" + expected
+ + " in:\n" + body);
+ }
+
+ /**
+ * The explicit SLO boundaries, not micrometer's ~70-bucket percentile
histogram.
+ * Guards the cardinality decision: a stray publishPercentileHistogram()
fails here.
+ */
+ @Test
+ @Timeout(240)
+ public void testDurationBucketsAreBounded() throws Exception {
+ startProcess(new String[]{"-config",
getConfig("tika-config-server-basic.json"),
+ "--metricsPort", String.valueOf(metricsPort)});
+ awaitServerStartup();
+ assertEquals(200, rmeta(TEST_HELLO_WORLD).getStatus());
+
+ String body = get(metricsEndPoint + MetricsServer.PATH).body();
+ long buckets = body
+ .lines()
+ .filter(l ->
l.startsWith("tika_server_requests_seconds_bucket{")
+ && l.contains("endpoint=\"rmeta\""))
+ .count();
+ int expected = TikaServerMetrics.DURATION_SLOS.length + 1;
+ assertEquals(expected, buckets, "expected SLO buckets + Inf, got " +
buckets + ":\n" + body);
+ }
+
+ private Response rmeta(String resource) {
+ return WebClient.create(endPoint + RMETA_PATH)
+ .accept("application/json")
+ .put(ClassLoader.getSystemResourceAsStream(resource));
+ }
+
+ private static void assertSample(String body, String name, String labels,
double expected) {
+ assertEquals(expected, sample(body, name, labels), 0.0, name + "{" +
labels + "}");
+ }
+
+ private static double sample(String body, String name, String labels) {
+ String labelled = labels.isEmpty() ? "" : "\\{" +
Pattern.quote(labels) + ",?\\}";
+ Matcher m = Pattern.compile("^" + Pattern.quote(name) + labelled + "
(\\S+)$",
+ Pattern.MULTILINE).matcher(body);
+ assertTrue(m.find(), "missing " + name + "{" + labels + "} in:\n" +
body);
+ return Double.parseDouble(m.group(1));
+ }
+
+ private static HttpResponse<String> get(String url) throws Exception {
+ return HttpClient.newHttpClient().send(
+ HttpRequest.newBuilder(URI.create(url)).GET().build(),
+ HttpResponse.BodyHandlers.ofString());
+ }
+
+ private String getConfig(String configName) {
+ try {
+ return ProcessUtils.escapeCommandLine(Paths
+ .get(TikaServerMetricsIntegrationTest.class
+ .getResource("/configs/" + configName)
+ .toURI())
+ .toAbsolutePath()
+ .toString());
+ } catch (URISyntaxException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/MetricsServerTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/MetricsServerTest.java
new file mode 100644
index 0000000000..5cad62f758
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/MetricsServerTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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.
+ */
+package org.apache.tika.server.core.metrics;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+
+import io.micrometer.core.instrument.Counter;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class MetricsServerTest {
+
+ private TikaServerMetrics metrics;
+ private MetricsServer server;
+ private String base;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ metrics = new TikaServerMetrics();
+
Counter.builder("tika_test_total").register(metrics.getRegistry()).increment(3);
+ server = new MetricsServer("localhost", 0, metrics);
+ server.start();
+ base = "http://localhost:" + server.getPort();
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ server.close();
+ metrics.close();
+ }
+
+ @Test
+ public void testScrape() throws Exception {
+ HttpResponse<String> response = get(base + MetricsServer.PATH);
+ assertEquals(200, response.statusCode());
+ assertTrue(response.headers().firstValue("Content-Type").orElse("")
+ .startsWith("text/plain; version=0.0.4"),
response.headers().toString());
+ String body = response.body();
+ assertTrue(body.contains("# TYPE tika_test_total counter"), body);
+ assertTrue(body.contains("tika_test_total 3.0"), body);
+ assertEquals(200, get(base + MetricsServer.PATH + "/").statusCode());
+ HttpResponse<String> head = HttpClient.newHttpClient().send(
+ HttpRequest.newBuilder(URI.create(base + MetricsServer.PATH))
+ .method("HEAD",
HttpRequest.BodyPublishers.noBody()).build(),
+ HttpResponse.BodyHandlers.ofString());
+ assertEquals(200, head.statusCode());
+ assertEquals("", head.body());
+ }
+
+ @Test
+ public void testOnlyMetricsPathIsServed() throws Exception {
+ assertEquals(404, get(base + "/tika").statusCode());
+ assertEquals(404, get(base + "/").statusCode());
+ HttpResponse<String> post = HttpClient.newHttpClient().send(
+ HttpRequest.newBuilder(URI.create(base + MetricsServer.PATH))
+ .POST(HttpRequest.BodyPublishers.noBody()).build(),
+ HttpResponse.BodyHandlers.ofString());
+ assertEquals(405, post.statusCode());
+ }
+
+ @Test
+ public void testClosedServerRefusesConnections() throws Exception {
+ assertEquals(200, get(base + MetricsServer.PATH).statusCode());
+ server.close();
+ assertThrows(IOException.class, () -> get(base + MetricsServer.PATH));
+ }
+
+ private static HttpResponse<String> get(String url) throws Exception {
+ return HttpClient.newHttpClient().send(
+ HttpRequest.newBuilder(URI.create(url)).GET().build(),
+ HttpResponse.BodyHandlers.ofString());
+ }
+}
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/RejectedReasonTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/RejectedReasonTest.java
new file mode 100644
index 0000000000..556bce87e3
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/RejectedReasonTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.
+ */
+package org.apache.tika.server.core.metrics;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.pipes.api.PipesResult;
+import org.apache.tika.server.core.resource.PipesParsingHelper;
+
+/**
+ * Ties {@code rejected_total} to the statuses tika-server actually returns,
so a new
+ * {@link PipesResult.RESULT_STATUS} that maps to 429/503/413 fails here until
someone
+ * decides which rejection reason it is.
+ */
+public class RejectedReasonTest {
+
+ private static final Map<PipesResult.RESULT_STATUS, String> EXPECTED = new
HashMap<>();
+
+ static {
+ EXPECTED.put(PipesResult.RESULT_STATUS.CLIENT_UNAVAILABLE_WITHIN_MS,
"busy_429");
+ EXPECTED.put(PipesResult.RESULT_STATUS.TIMEOUT, "crash_503");
+ EXPECTED.put(PipesResult.RESULT_STATUS.OOM, "crash_503");
+ EXPECTED.put(PipesResult.RESULT_STATUS.UNSPECIFIED_CRASH, "crash_503");
+ EXPECTED.put(PipesResult.RESULT_STATUS.PAYLOAD_LIMIT_EXCEEDED,
"payload_413");
+ }
+
+ @Test
+ public void testEveryStatusIsClassified() {
+ for (PipesResult.RESULT_STATUS status :
PipesResult.RESULT_STATUS.values()) {
+ int httpStatus = PipesParsingHelper
+ .responseBuilder(status, 1000)
+ .build()
+ .getStatus();
+ assertEquals(EXPECTED.get(status),
TikaServerMetrics.rejectedReason(httpStatus),
+ status + " maps to HTTP " + httpStatus);
+ }
+ }
+
+ @Test
+ public void testNonRejectionStatusesAreNotCounted() {
+ assertNull(TikaServerMetrics.rejectedReason(200));
+ assertNull(TikaServerMetrics.rejectedReason(400));
+ assertNull(TikaServerMetrics.rejectedReason(500));
+ }
+}
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/TikaMetricsFilterTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/TikaMetricsFilterTest.java
new file mode 100644
index 0000000000..911e840143
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/TikaMetricsFilterTest.java
@@ -0,0 +1,269 @@
+/*
+ * 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.
+ */
+package org.apache.tika.server.core.metrics;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.net.Socket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.DistributionSummary;
+import io.micrometer.core.instrument.Timer;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.InternalServerErrorException;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.StreamingOutput;
+import org.apache.cxf.jaxrs.JAXRSServerFactoryBean;
+import org.apache.cxf.jaxrs.client.WebClient;
+import org.apache.cxf.jaxrs.lifecycle.SingletonResourceProvider;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import org.apache.tika.server.core.CXFTestBase;
+import org.apache.tika.server.core.MaxRequestSizeFilter;
+import org.apache.tika.server.core.TikaServerParseExceptionMapper;
+import org.apache.tika.server.core.resource.TikaResource;
+import org.apache.tika.server.core.writer.JSONMessageBodyWriter;
+
+public class TikaMetricsFilterTest extends CXFTestBase {
+
+ private static final long MAX_BYTES = 64 * 1024;
+ private static final String HELLO_WORLD =
"test-documents/mock/hello_world.xml";
+
+ private TikaServerMetrics metrics;
+
+ @Path("/boom")
+ public static class BoomResource {
+ @GET
+ public String get() {
+ // Mapped by JAX-RS itself; an unmapped exception never reaches
response filters.
+ throw new InternalServerErrorException("boom");
+ }
+
+ @GET
+ @Path("mid-write")
+ public StreamingOutput midWrite() {
+ return out -> {
+ out.write("partial".getBytes(StandardCharsets.UTF_8));
+ out.flush();
+ throw new InternalServerErrorException("mid-write");
+ };
+ }
+ }
+
+ @Override
+ protected void setUpResources(JAXRSServerFactoryBean sf) {
+ sf.setResourceClasses(TikaResource.class, BoomResource.class);
+ sf.setResourceProvider(TikaResource.class, new
SingletonResourceProvider(tikaResource));
+ sf.setResourceProvider(BoomResource.class, new
SingletonResourceProvider(new BoomResource()));
+ }
+
+ @Override
+ protected void setUpProviders(JAXRSServerFactoryBean sf) {
+ metrics = new TikaServerMetrics();
+ List<Object> providers = new ArrayList<>();
+ providers.add(new TikaServerParseExceptionMapper());
+ providers.add(new JSONMessageBodyWriter());
+ providers.add(new MaxRequestSizeFilter(MAX_BYTES));
+ providers.add(new
MaxRequestSizeFilter.RequestTooLargeExceptionMapper());
+ providers.add(new TikaMetricsFilter(metrics, Set.of("tika", "rmeta")));
+ sf.setProviders(providers);
+ }
+
+ @Test
+ public void testSuccessRecordsTimerAndSize() throws Exception {
+ long helloWorldBytes;
+ try (InputStream is =
ClassLoader.getSystemResourceAsStream(HELLO_WORLD)) {
+ helloWorldBytes = is.readAllBytes().length;
+ }
+ long before = count("tika", "PUT", "2xx");
+ long sizeBefore = sizeCount("tika");
+ double sizeTotalBefore = sizeTotal("tika");
+ Response response = WebClient
+ .create(endPoint + "/tika/text")
+ .accept("text/plain")
+ .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+ assertEquals(200, response.getStatus());
+ String body = getStringFromInputStream((InputStream)
response.getEntity());
+ assertTrue(body.contains("hello world"), body);
+
+ assertEquals(before + 1, count("tika", "PUT", "2xx"));
+ assertEquals(sizeBefore + 1, sizeCount("tika"));
+ assertEquals(sizeTotalBefore + helloWorldBytes, sizeTotal("tika"),
0.0);
+ }
+
+ /** WebClient streams an InputStream chunked, so this is the
BoundedInputStream + mapper path. */
+ @Test
+ public void testChunkedPayloadTooLargeCountsAsRejected() {
+ long before = count("tika", "PUT", "4xx");
+ double rejectedBefore = rejected("payload_413");
+ Response response = WebClient
+ .create(endPoint + "/tika/text")
+ .put(new ByteArrayInputStream(new byte[(int) MAX_BYTES * 4]));
+ assertEquals(413, response.getStatus());
+ assertEquals(before + 1, count("tika", "PUT", "4xx"));
+ assertEquals(rejectedBefore + 1, rejected("payload_413"), 0.0);
+ }
+
+ /** A declared Content-Length over the limit is refused via abortWith
before the body is read. */
+ @Test
+ public void testDeclaredLengthTooLargeCountsAsRejected() throws Exception {
+ long before = count("tika", "PUT", "4xx");
+ double rejectedBefore = rejected("payload_413");
+ HttpResponse<String> response = put(
+ HttpRequest.BodyPublishers.ofByteArray(new byte[(int)
MAX_BYTES * 4]));
+ assertEquals(413, response.statusCode());
+ assertEquals(before + 1, count("tika", "PUT", "4xx"));
+ assertEquals(rejectedBefore + 1, rejected("payload_413"), 0.0);
+ }
+
+ @Test
+ public void testChunkedRequestHasNoSizeSample() throws Exception {
+ long before = count("tika", "PUT", "2xx");
+ long sizeBefore = sizeCount("tika");
+ HttpResponse<String> response =
put(HttpRequest.BodyPublishers.ofInputStream(
+ () -> ClassLoader.getSystemResourceAsStream(HELLO_WORLD)));
+ assertEquals(200, response.statusCode());
+ assertEquals(before + 1, count("tika", "PUT", "2xx"));
+ assertEquals(sizeBefore, sizeCount("tika"), "no Content-Length must
mean no size sample");
+ }
+
+ @Test
+ public void testThrownExceptionIs5xx() {
+ long before = count(TikaMetricsFilter.OTHER, "GET", "5xx");
+ Response response = WebClient.create(endPoint + "/boom").get();
+ assertEquals(500, response.getStatus());
+ // /boom is not a tika-server endpoint name, so it folds into "other"
+ assertEquals(before + 1, count(TikaMetricsFilter.OTHER, "GET", "5xx"));
+ }
+
+ /** CXF re-runs the response filters for the mapped exception; the request
must be timed once. */
+ @Test
+ public void testMidWriteExceptionRecordedOnce() {
+ long before = count(TikaMetricsFilter.OTHER, "GET");
+ WebClient.create(endPoint + "/boom/mid-write").get();
+ assertEquals(before + 1, count(TikaMetricsFilter.OTHER, "GET"));
+ }
+
+ private HttpResponse<String> put(HttpRequest.BodyPublisher body) throws
Exception {
+ return HttpClient.newHttpClient().send(
+ HttpRequest.newBuilder(URI.create(endPoint + "/tika/text"))
+ .header("Accept", "text/plain").PUT(body).build(),
+ HttpResponse.BodyHandlers.ofString());
+ }
+
+ @Test
+ public void testUnmatchedPathsAreBoundedTags() {
+ long timersBefore = -1;
+ for (int i = 0; i < 25; i++) {
+ Response response = WebClient.create(endPoint + "/no-such-" + i +
"/x").get();
+ assertEquals(404, response.getStatus());
+ long timers =
metrics.getRegistry().find(TikaServerMetrics.REQUESTS).timers().size();
+ if (timersBefore >= 0) {
+ assertEquals(timersBefore, timers, "a new request timer
appeared for request " + i);
+ }
+ timersBefore = timers;
+ }
+ assertEquals(25, count(TikaMetricsFilter.UNMATCHED, "GET", "4xx"));
+ }
+
+ @Test
+ public void testUnknownMethodsAreBoundedTags() throws Exception {
+ long timersBefore = -1;
+ for (int i = 0; i < 25; i++) {
+ assertTrue(rawRequest("BOGUS" + i + " /tika
HTTP/1.1").startsWith("HTTP/1.1"));
+ long timers =
metrics.getRegistry().find(TikaServerMetrics.REQUESTS).timers().size();
+ if (timersBefore >= 0) {
+ assertEquals(timersBefore, timers, "a new request timer
appeared for method BOGUS" + i);
+ }
+ timersBefore = timers;
+ }
+ assertNull(timer("tika", "BOGUS0", "4xx"), "the raw method must not
become a tag value");
+ assertEquals(25, count("tika", "other"), "requests with unknown
methods must still be counted");
+ }
+
+ @ParameterizedTest
+ @CsvSource(nullValues = "null", value = {
+ "GET, GET", "PUT, PUT", "BOGUS, other", "get, other", "null,
other"})
+ public void testMethodTagIsBounded(String method, String expected) {
+ assertEquals(expected, TikaServerMetrics.methodTag(method));
+ }
+
+ /** Arbitrary method tokens: HttpURLConnection and java.net.http both
refuse to send them. */
+ private String rawRequest(String requestLine) throws Exception {
+ URI uri = URI.create(endPoint);
+ try (Socket socket = new Socket(uri.getHost(), uri.getPort())) {
+ socket.setSoTimeout(30000);
+ socket.getOutputStream().write(
+ (requestLine + "\r\nHost: " + uri.getHost() +
"\r\nConnection: close\r\n\r\n")
+ .getBytes(StandardCharsets.US_ASCII));
+ socket.getOutputStream().flush();
+ return new String(socket.getInputStream().readAllBytes(),
StandardCharsets.US_ASCII);
+ }
+ }
+
+ private Timer timer(String endpoint, String method, String status) {
+ return metrics.getRegistry().find(TikaServerMetrics.REQUESTS)
+ .tag("endpoint", endpoint).tag("method", method).tag("status",
status).timer();
+ }
+
+ /** Registry is shared across the class (PER_CLASS), so callers assert
deltas; absent meters read 0. */
+ private long count(String endpoint, String method, String status) {
+ Timer t = timer(endpoint, method, status);
+ return t == null ? 0 : t.count();
+ }
+
+ private long count(String endpoint, String method) {
+ return metrics.getRegistry().find(TikaServerMetrics.REQUESTS)
+ .tag("endpoint", endpoint).tag("method",
method).timers().stream()
+ .mapToLong(Timer::count).sum();
+ }
+
+ private DistributionSummary size(String endpoint) {
+ return metrics.getRegistry().find(TikaServerMetrics.REQUEST_SIZE)
+ .tag("endpoint", endpoint).summary();
+ }
+
+ private long sizeCount(String endpoint) {
+ DistributionSummary s = size(endpoint);
+ return s == null ? 0 : s.count();
+ }
+
+ private double sizeTotal(String endpoint) {
+ DistributionSummary s = size(endpoint);
+ return s == null ? 0 : s.totalAmount();
+ }
+
+ private double rejected(String reason) {
+ Counter c =
metrics.getRegistry().find(TikaServerMetrics.REJECTED).tag("reason",
reason).counter();
+ return c == null ? 0 : c.count();
+ }
+}
diff --git
a/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-async-metrics.json
b/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-async-metrics.json
new file mode 100644
index 0000000000..40c3ab50fc
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-async-metrics.json
@@ -0,0 +1,24 @@
+{
+ "server": {
+ "port": 9999,
+ "allowPipes": true,
+ "allowPerRequestConfig": true,
+ "endpoints": [
+ "rmeta",
+ "async",
+ "status"
+ ]
+ },
+ "parse-context": {
+ "timeout-limits": {
+ "progressTimeoutMillis": 120000
+ }
+ },
+ "pipes": {
+ "numClients": 2,
+ "forkedJvmArgs": [
+ "-Xmx256m"
+ ]
+ },
+ "plugin-roots": "target/plugins"
+}
diff --git
a/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics-restarts.json
b/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics-restarts.json
new file mode 100644
index 0000000000..665245531c
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics-restarts.json
@@ -0,0 +1,18 @@
+{
+ "server": {
+ "port": 9999,
+ "allowPipes": true,
+ "endpoints": [
+ "rmeta"
+ ]
+ },
+ "pipes": {
+ "numClients": 1,
+ "maxFilesProcessedPerProcess": 2,
+ "socketTimeoutMillis": 2000,
+ "forkedJvmArgs": [
+ "-Xmx256m"
+ ]
+ },
+ "plugin-roots": "target/plugins"
+}
diff --git
a/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics-shared.json
b/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics-shared.json
new file mode 100644
index 0000000000..5a558f52bc
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics-shared.json
@@ -0,0 +1,17 @@
+{
+ "server": {
+ "port": 9999,
+ "allowPipes": true,
+ "endpoints": [
+ "rmeta"
+ ]
+ },
+ "pipes": {
+ "useSharedServer": true,
+ "numClients": 2,
+ "forkedJvmArgs": [
+ "-Xmx256m"
+ ]
+ },
+ "plugin-roots": "target/plugins"
+}
diff --git
a/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics-timeout.json
b/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics-timeout.json
new file mode 100644
index 0000000000..53be01e315
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics-timeout.json
@@ -0,0 +1,21 @@
+{
+ "server": {
+ "port": 9999,
+ "allowPipes": true,
+ "endpoints": [
+ "rmeta"
+ ]
+ },
+ "parse-context": {
+ "timeout-limits": {
+ "progressTimeoutMillis": 5000
+ }
+ },
+ "pipes": {
+ "numClients": 1,
+ "forkedJvmArgs": [
+ "-Xmx256m"
+ ]
+ },
+ "plugin-roots": "target/plugins"
+}
diff --git
a/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics.json
b/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics.json
new file mode 100644
index 0000000000..16dc5c799b
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics.json
@@ -0,0 +1,10 @@
+{
+ "server": {
+ "port": 9999,
+ "endpoints": [
+ "tika"
+ ],
+ "metricsPort": 9404
+ },
+ "plugin-roots": "target/plugins"
+}
diff --git
a/tika-server/tika-server-standard/src/main/appended-resources/META-INF/LICENSE
b/tika-server/tika-server-standard/src/main/appended-resources/META-INF/LICENSE
index f4f9fb4839..05c659a0d3 100644
---
a/tika-server/tika-server-standard/src/main/appended-resources/META-INF/LICENSE
+++
b/tika-server/tika-server-standard/src/main/appended-resources/META-INF/LICENSE
@@ -1872,3 +1872,87 @@ jetty-session, jetty-util)
Apache Tika elects the Apache License, Version 2.0, whose terms appear
above. The Jetty jars do not carry a copy of either license; both texts
are available at the URLs above.
+
+Prometheus Java client (prometheus-metrics-core, prometheus-metrics-config,
+prometheus-metrics-model, prometheus-metrics-exposition-formats,
+prometheus-metrics-exposition-textformats, prometheus-metrics-tracer-common)
+
+ Copyright The Prometheus Authors
+
+ Licensed under the Apache License, Version 2.0, whose terms appear above.
+ The jars do not carry a copy of the license; the upstream project is
+ https://github.com/prometheus/client_java
+
+ prometheus-metrics-exposition-formats bundles a shaded copy of Protocol
+ Buffers (protobuf-java), Copyright 2008 Google Inc., under the BSD 3-clause
+ license:
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are
+ met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following disclaimer
+ in the documentation and/or other materials provided with the
+ distribution.
+ * Neither the name of Google Inc. nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+HdrHistogram library (HdrHistogram)
+
+ Written by Gil Tene, Michael Barker and Matt Warren, and released to the
+ public domain, as explained at
+ https://creativecommons.org/publicdomain/zero/1.0/
+
+ HdrHistogram is dual-licensed: CC0 1.0 Universal (public domain
+ dedication) or the 2-clause BSD license below. Apache Tika elects the
+ 2-clause BSD license. The jar carries both texts in META-INF/LICENSE.txt;
+ the upstream project is https://github.com/HdrHistogram/HdrHistogram
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+
+ 1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ POSSIBILITY OF SUCH DAMAGE.
+
+Micrometer (micrometer-core, micrometer-commons, micrometer-observation,
+micrometer-registry-prometheus)
+
+ Copyright (c) 2017-Present VMware, Inc. All Rights Reserved.
+
+ Licensed under the Apache License, Version 2.0, whose terms appear above.
+
+JSpecify (jspecify)
+
+ Licensed under the Apache License, Version 2.0, whose terms appear above.