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 ac31d0c94a TIKA-4846 -- add jsonl reporter (#3082)
ac31d0c94a is described below
commit ac31d0c94a3474bd103f44f74aca663c86d3ba9d
Author: Tim Allison <[email protected]>
AuthorDate: Thu Aug 27 16:46:03 2026 -0400
TIKA-4846 -- add jsonl reporter (#3082)
---
CHANGES.txt | 2 +
.../ROOT/examples/pipes-fs-jsonl-reporter.json | 1 +
.../ROOT/pages/pipes/plugins/filesystem.adoc | 61 +++++
docs/modules/ROOT/pages/pipes/reporters.adoc | 6 +
docs/modules/ROOT/pages/using-tika/cli/index.adoc | 2 +-
.../org/apache/tika/async/cli/PluginsWriter.java | 11 +
.../tika/async/cli/TikaConfigAsyncWriterTest.java | 24 ++
.../tika/pipes/core/async/AsyncProcessor.java | 48 +++-
.../core/reporter/CompositePipesReporter.java | 41 +++-
.../core/reporter/CompositePipesReporterTest.java | 101 ++++++++
.../tika-pipes-file-system/pom.xml | 5 +
.../pipes/reporter/fs/FileSystemJsonlReporter.java | 216 +++++++++++++++++
.../reporter/fs/FileSystemJsonlReporterConfig.java | 48 ++++
.../fs/FileSystemJsonlReporterFactory.java | 41 ++++
.../apache/tika/pipes/fs/ConfigExamplesTest.java | 42 +++-
.../reporter/fs/FileSystemJsonlReporterTest.java | 257 +++++++++++++++++++++
.../file-system-jsonl-reporter.json | 10 +
17 files changed, 905 insertions(+), 11 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 3b336d2e81..4d7573febc 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,7 @@
Release 4.1.0 - unreleased
+ * New file-system-jsonl-reporter pipes reporter (TIKA-4846).
+
* Stop spooling OLE2 objects whose header over-reserves BAT capacity
(TIKA-4845).
diff --git a/docs/modules/ROOT/examples/pipes-fs-jsonl-reporter.json
b/docs/modules/ROOT/examples/pipes-fs-jsonl-reporter.json
new file mode 120000
index 0000000000..e83e6c241b
--- /dev/null
+++ b/docs/modules/ROOT/examples/pipes-fs-jsonl-reporter.json
@@ -0,0 +1 @@
+../../../../tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/resources/config-examples/file-system-jsonl-reporter.json
\ No newline at end of file
diff --git a/docs/modules/ROOT/pages/pipes/plugins/filesystem.adoc
b/docs/modules/ROOT/pages/pipes/plugins/filesystem.adoc
index 6fc9333f20..266186421f 100644
--- a/docs/modules/ROOT/pages/pipes/plugins/filesystem.adoc
+++ b/docs/modules/ROOT/pages/pipes/plugins/filesystem.adoc
@@ -40,6 +40,10 @@ The File System plugin (`tika-pipes-file-system`) is the
most common starting po
|Reporter
|`file-system-reporter`
|`FileSystemStatusReporter`
+
+|Reporter
+|`file-system-jsonl-reporter`
+|`FileSystemJsonlReporter`
|===
== Complete Pipeline Example
@@ -252,6 +256,63 @@ Tradeoffs:
* The reporter thread sleeps between writes, so the worst-case staleness of
the file is `reportUpdateMs` milliseconds plus serialization time.
* Per-record `report()` calls are cheap (counter increment only). The cost of
"watching" is bounded by the periodic write, not by document throughput.
+[#file-system-jsonl-reporter]
+== File System JSONL Reporter (`file-system-jsonl-reporter`)
+
+Append-only per-document audit log: one JSON object per line for every result
that passes the `includes`/`excludes` filter. Where the status reporter above
summarizes counts, this one records *which* documents ended in which state, so
a downstream process (a dead-letter queue, `tika-eval`) can act on them.
+
+With the default `emitIntermediateResults: false` (see
xref:pipes/configuration.adoc[]), a crash result (`OOM`, `TIMEOUT`,
`UNSPECIFIED_CRASH`) leaves nothing in the emitter's output; this file is then
the only record that the document was attempted. The driver process writes it,
so it survives the forked worker's death.
+
+[source,json]
+----
+include::example$pipes-fs-jsonl-reporter.json[]
+----
+
+=== Line format
+
+[source,json]
+----
+{"id":"reports/q3.pdf","status":"OOM","category":"PROCESS_CRASH","message":"...","elapsedMs":4120,"timestamp":"2026-08-27T14:02:11.482Z"}
+----
+
+* `id` — `FetchEmitTuple.getId()` verbatim. For the file system iterator that
is the path relative to `basePath`, so it matches the emitted file name minus
the emitter's `fileExtension`.
+* `status` — `PipesResult.RESULT_STATUS` name.
+* `category` — the status's `PipesResult.CATEGORY` (`PROCESS_CRASH`,
`TASK_EXCEPTION`, ...), so consumers can group without tracking every status.
+* `message` — the result's message when the worker managed to send one (a
stack trace for crashes); `null` when it did not. Capped at `maxMessageLength`.
+* `elapsedMs` — wall-clock time the driver spent on this document: fetch,
parse, any emit done inside the worker (`EMIT_SUCCESS*` statuses), and any wait
for the driver's emit queue. Emits batched by the driver (`PARSE_SUCCESS*`)
happen later and are not included.
+* `timestamp` — ISO-8601 UTC timestamp of the report.
+
+Each line is flushed to the OS before `report` returns, so it survives the
driver process dying; there is no fsync, so a host crash can lose the last
lines. A write failure (disk full, etc.) stops the whole run rather than
continuing without a record.
+
+If the pipeline dies, a final line of a different shape, `{"error":"<stack
trace>","timestamp":"..."}`, is written and the file is closed. Readers should
dispatch on the presence of `id` vs `error`.
+
+Fields may be added in later releases; existing fields will not be renamed or
removed.
+
+=== Configuration
+
+[cols="1,1,3"]
+|===
+|Field |Default |Description
+
+|`path`
+|_required_
+|Path of the JSONL file, resolved against the driver's working directory if
relative. Missing parent directories are created at startup.
+
+|`onExists`
+|`EXCEPTION`
+|What to do when `path` already exists at startup: `EXCEPTION` refuses to
start, `APPEND` continues the existing file (terminating a partial last line
left by a killed run, with a warning), `REPLACE` truncates it. There is no
`SKIP`, unlike the emitter's `onExists`; `tika-async-cli --on-exists` applies
to this reporter too, mapping `skip` to `APPEND`. The default is deliberately
strict — appending a new run onto an old ledger is the mistake this reporter
exists to prevent.
+
+|`includes` / `excludes`
+|_all statuses_
+|Mutually exclusive sets of `RESULT_STATUS` names. For a crash ledger,
`includes` the crash and exception statuses; leave both unset for a full
per-document audit trail.
+
+|`maxMessageLength`
+|`10000`
+|Characters of `message` (and of the final `error` line) to keep; longer
messages are truncated with a suffix stating how many characters were dropped.
`0` means the default; negative values are rejected.
+|===
+
+Each line is written and flushed before `report()` returns, so the ordering
across documents is the order the driver finished them, not the iterator's
order. If a write fails (disk full), that `report()` and every later one throw,
which aborts the pipeline rather than dropping lines silently.
+
[#security-notes]
== Security Notes
diff --git a/docs/modules/ROOT/pages/pipes/reporters.adoc
b/docs/modules/ROOT/pages/pipes/reporters.adoc
index 01bc05e604..25f704d2cc 100644
--- a/docs/modules/ROOT/pages/pipes/reporters.adoc
+++ b/docs/modules/ROOT/pages/pipes/reporters.adoc
@@ -47,6 +47,8 @@ Reporters live under the plural top-level `pipes-reporters`
key. The keys inside
Each entry's outer key is the reporter's component name — there is no separate
ID layer because reporters do not get referenced by other components.
+Every configured reporter receives every call even if another reporter throws;
the first exception is rethrown afterward and stops the run.
+
[#plugins]
== Available Reporters
@@ -58,6 +60,10 @@ Each entry's outer key is the reporter's component name —
there is no separate
|`file-system-reporter`
|Writes a JSON status file periodically. Pair with an external watcher — see
xref:pipes/plugins/filesystem.adoc#watching[Live status for watching
applications].
+|xref:pipes/plugins/filesystem.adoc#file-system-jsonl-reporter[File System]
+|`file-system-jsonl-reporter`
+|Appends one JSON line per document to a file. The record of documents whose
worker crashed (by default nothing reaches the emitter for those).
+
|xref:pipes/plugins/jdbc.adoc[JDBC]
|`jdbc-reporter`
|Writes per-doc status rows to a SQL table.
diff --git a/docs/modules/ROOT/pages/using-tika/cli/index.adoc
b/docs/modules/ROOT/pages/using-tika/cli/index.adoc
index f4979f4181..c02c2f8771 100644
--- a/docs/modules/ROOT/pages/using-tika/cli/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/cli/index.adoc
@@ -322,7 +322,7 @@ as usual.
|File list, one path per line, relative to `--inputDir` or absolute.
|`--on-exists=<mode>`
-|Behavior when an output file already exists: `exception` (default),
`replace`, `skip`.
+|Behavior when an output file already exists: `exception` (default),
`replace`, `skip`. Also applied to a configured `file-system-jsonl-reporter`
(`skip` becomes `APPEND` there).
|===
=== Output formatting
diff --git
a/tika-pipes/tika-async-cli/src/main/java/org/apache/tika/async/cli/PluginsWriter.java
b/tika-pipes/tika-async-cli/src/main/java/org/apache/tika/async/cli/PluginsWriter.java
index fb7ae28278..23d0547ca0 100644
---
a/tika-pipes/tika-async-cli/src/main/java/org/apache/tika/async/cli/PluginsWriter.java
+++
b/tika-pipes/tika-async-cli/src/main/java/org/apache/tika/async/cli/PluginsWriter.java
@@ -162,6 +162,7 @@ public class PluginsWriter {
if (!StringUtils.isBlank(simpleAsyncConfig.getOnExists())) {
patchFileSystemField(root, "emitters", "file-system-emitter",
"onExists", simpleAsyncConfig.getOnExists());
+ patchJsonlReporterOnExists(root,
simpleAsyncConfig.getOnExists());
}
// merge, don't replace: other configured timeout-limits fields
must survive
@@ -215,6 +216,16 @@ public class PluginsWriter {
}
}
+ // the jsonl reporter has no SKIP; a rerun that keeps old outputs should
keep the old ledger too
+ private static void patchJsonlReporterOnExists(ObjectNode root, String
emitterOnExists) {
+ JsonNode reporters = root.get("pipes-reporters");
+ if (reporters == null || !reporters.isObject() ||
!reporters.has("file-system-jsonl-reporter")) {
+ return;
+ }
+ String mapped = "SKIP".equalsIgnoreCase(emitterOnExists) ? "APPEND" :
emitterOnExists;
+ ((ObjectNode)
reporters.get("file-system-jsonl-reporter")).put("onExists", mapped);
+ }
+
/**
* Sets {@code basePath} on a singleton section ({@code pipes-iterator})
* whose wrapper type matches {@code typeName}.
diff --git
a/tika-pipes/tika-async-cli/src/test/java/org/apache/tika/async/cli/TikaConfigAsyncWriterTest.java
b/tika-pipes/tika-async-cli/src/test/java/org/apache/tika/async/cli/TikaConfigAsyncWriterTest.java
index 2f908aa005..fa6c69fb73 100644
---
a/tika-pipes/tika-async-cli/src/test/java/org/apache/tika/async/cli/TikaConfigAsyncWriterTest.java
+++
b/tika-pipes/tika-async-cli/src/test/java/org/apache/tika/async/cli/TikaConfigAsyncWriterTest.java
@@ -83,4 +83,28 @@ public class TikaConfigAsyncWriterTest {
assertEquals(60000L, timeouts.path("progressTimeoutMillis").asLong());
assertTrue(timeouts.path("throwOnDeadline").asBoolean());
}
+
+ @Test
+ public void testOnExistsReachesJsonlReporter(@TempDir Path dir) throws
Exception {
+ Path config = dir.resolve("config.json");
+ Files.writeString(config, """
+ {
+ "pipes-reporters": {
+ "file-system-jsonl-reporter": { "path": "audit.jsonl" }
+ }
+ }
+ """);
+ for (String[] pair : new String[][]{{"REPLACE", "REPLACE"}, {"SKIP",
"APPEND"}, {"EXCEPTION", "EXCEPTION"}}) {
+ SimpleAsyncConfig simpleAsyncConfig = new
SimpleAsyncConfig("input", "output", 4,
+ null, null, null,
config.toAbsolutePath().toString().replace("\\", "/"),
+ BasicContentHandlerFactory.HANDLER_TYPE.TEXT,
+ SimpleAsyncConfig.ExtractBytesMode.NONE, null);
+ simpleAsyncConfig.setOnExists(pair[0]);
+ Path tmp = Files.createTempFile(dir, "plugins-", ".json");
+ new PluginsWriter(simpleAsyncConfig, null).write(tmp);
+ JsonNode root = new ObjectMapper().readTree(tmp.toFile());
+ assertEquals(pair[1],
root.path("pipes-reporters").path("file-system-jsonl-reporter")
+ .path("onExists").asText(), pair[0]);
+ }
+ }
}
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 dbb8336e71..70849fa8c7 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
@@ -31,6 +31,7 @@ import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -79,6 +80,8 @@ public class AsyncProcessor implements Closeable {
private final List<ServerManager> serverManagers = new ArrayList<>();
private final AtomicLong totalProcessed = new AtomicLong(0);
private final AtomicBoolean applicationErrorOccurred = new
AtomicBoolean(false);
+ // first worker/emitter failure; reported to the reporter exactly once
+ private final AtomicReference<ExecutionException> failure = new
AtomicReference<>();
private static long MAX_OFFER_WAIT_MS = 120000;
private volatile int numParserThreadsFinished = 0;
private volatile int numEmitterThreadsFinished = 0;
@@ -142,6 +145,12 @@ public class AsyncProcessor implements Closeable {
checkActive();
} catch (InterruptedException e) {
return WATCHER_FUTURE_CODE;
+ } catch (RuntimeException e) {
+ if (failure.get() == null) {
+ throw e;
+ }
+ // already latched in failure; rethrowing would make
this future a second one
+ return WATCHER_FUTURE_CODE;
}
}
});
@@ -322,7 +331,9 @@ public class AsyncProcessor implements Closeable {
}
public synchronized boolean checkActive() throws InterruptedException {
-
+ if (failure.get() != null) {
+ throw new RuntimeException(failure.get());
+ }
Future<Integer> future = executorCompletionService.poll();
if (future != null) {
try {
@@ -343,8 +354,15 @@ public class AsyncProcessor implements Closeable {
throw new IllegalArgumentException("Don't recognize
this future code: " + i);
}
} catch (ExecutionException e) {
- LOG.error("execution exception", e);
- this.pipesReporter.error(e);
+ if (failure.compareAndSet(null, e)) {
+ LOG.error("execution exception", e);
+ try {
+ this.pipesReporter.error(e);
+ } catch (RuntimeException re) {
+ // the worker failure is the primary; don't let the
reporter mask it
+ e.addSuppressed(re);
+ }
+ }
throw new RuntimeException(e);
}
}
@@ -448,10 +466,16 @@ public class AsyncProcessor implements Closeable {
describeStopReason(result),
result.status());
applicationErrorOccurred.set(true);
- pipesReporter.report(t, result,
System.currentTimeMillis() - start);
- throw new
PipesException(describeStopReason(result) + ": " +
+ PipesException stop = new
PipesException(describeStopReason(result) + ": " +
result.status() +
(result.message() != null ? " - " +
result.message() : ""));
+ try {
+ pipesReporter.report(t, result,
System.currentTimeMillis() - start);
+ } catch (RuntimeException e) {
+ // the stop reason is the primary failure;
don't let the reporter mask it
+ stop.addSuppressed(e);
+ }
+ throw stop;
}
if (LOG.isTraceEnabled()) {
LOG.trace("timer -- pipes client process: {} ms",
@@ -474,13 +498,25 @@ public class AsyncProcessor implements Closeable {
System.currentTimeMillis() - offerStart);
}
long elapsed = System.currentTimeMillis() - start;
- pipesReporter.report(t, result, elapsed);
+ report(t, result, elapsed);
totalProcessed.incrementAndGet();
}
}
}
}
+ // a reporter that throws must stop every worker, or the other workers
keep
+ // emitting documents that never get an audit line
+ private void report(FetchEmitTuple t, PipesResult result, long
elapsed) throws PipesException {
+ try {
+ pipesReporter.report(t, result, elapsed);
+ } catch (RuntimeException e) {
+ LOG.error("reporter failed; stopping all processing", e);
+ applicationErrorOccurred.set(true);
+ throw new PipesException("reporter failed", e);
+ }
+ }
+
private boolean shouldEmit(PipesResult result) {
// emitData is null for SUCCESS statuses where the server already
emitted
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/reporter/CompositePipesReporter.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/reporter/CompositePipesReporter.java
index f625c67230..de885b6664 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/reporter/CompositePipesReporter.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/reporter/CompositePipesReporter.java
@@ -33,12 +33,31 @@ public class CompositePipesReporter implements
PipesReporter {
pipesReporters = pipesReporterList;
}
+ /**
+ * Every reporter sees every call, even if an earlier one throws; the first
+ * exception is rethrown after the loop with the rest suppressed.
+ */
@Override
public void report(FetchEmitTuple t, PipesResult result, long elapsed) {
+ RuntimeException first = null;
for (PipesReporter reporter : pipesReporters) {
- reporter.report(t, result, elapsed);
+ try {
+ reporter.report(t, result, elapsed);
+ } catch (RuntimeException e) {
+ first = collect(first, e);
+ }
}
+ if (first != null) {
+ throw first;
+ }
+ }
+ private static RuntimeException collect(RuntimeException first,
RuntimeException e) {
+ if (first == null) {
+ return e;
+ }
+ first.addSuppressed(e);
+ return first;
}
@Override
@@ -60,15 +79,31 @@ public class CompositePipesReporter implements
PipesReporter {
@Override
public void error(Throwable t) {
+ RuntimeException first = null;
for (PipesReporter reporter : pipesReporters) {
- reporter.error(t);
+ try {
+ reporter.error(t);
+ } catch (RuntimeException e) {
+ first = collect(first, e);
+ }
+ }
+ if (first != null) {
+ throw first;
}
}
@Override
public void error(String msg) {
+ RuntimeException first = null;
for (PipesReporter reporter : pipesReporters) {
- reporter.error(msg);
+ try {
+ reporter.error(msg);
+ } catch (RuntimeException e) {
+ first = collect(first, e);
+ }
+ }
+ if (first != null) {
+ throw first;
}
}
diff --git
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/reporter/CompositePipesReporterTest.java
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/reporter/CompositePipesReporterTest.java
new file mode 100644
index 0000000000..44f45040e7
--- /dev/null
+++
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/reporter/CompositePipesReporterTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.reporter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.pipes.api.FetchEmitTuple;
+import org.apache.tika.pipes.api.PipesResult;
+import org.apache.tika.pipes.api.emitter.EmitKey;
+import org.apache.tika.pipes.api.fetcher.FetchKey;
+import org.apache.tika.pipes.api.pipesiterator.TotalCountResult;
+import org.apache.tika.pipes.api.reporter.PipesReporter;
+import org.apache.tika.plugins.ExtensionConfig;
+
+public class CompositePipesReporterTest {
+
+ private static class Recording implements PipesReporter {
+ final List<String> seen = new ArrayList<>();
+ final boolean throwing;
+
+ Recording(boolean throwing) {
+ this.throwing = throwing;
+ }
+
+ @Override
+ public void report(FetchEmitTuple t, PipesResult result, long elapsed)
{
+ seen.add(t.getId());
+ if (throwing) {
+ throw new IllegalStateException("boom " + t.getId());
+ }
+ }
+
+ @Override
+ public void report(TotalCountResult totalCountResult) {
+ }
+
+ @Override
+ public boolean supportsTotalCount() {
+ return false;
+ }
+
+ @Override
+ public void error(Throwable t) {
+ seen.add("error");
+ if (throwing) {
+ throw new IllegalStateException("boom error");
+ }
+ }
+
+ @Override
+ public void error(String msg) {
+ error(new RuntimeException(msg));
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public ExtensionConfig getExtensionConfig() {
+ return null;
+ }
+ }
+
+ @Test
+ public void testThrowingReporterDoesNotStarveSiblings() {
+ Recording first = new Recording(true);
+ Recording second = new Recording(false);
+ CompositePipesReporter composite = new
CompositePipesReporter(List.of(first, second));
+ FetchEmitTuple t = new FetchEmitTuple("a", new FetchKey("f", "a"), new
EmitKey("e", "a"));
+ PipesResult result = new PipesResult(PipesResult.RESULT_STATUS.OOM);
+
+ IllegalStateException e = assertThrows(IllegalStateException.class,
+ () -> composite.report(t, result, 1));
+ assertEquals("boom a", e.getMessage());
+ assertEquals(List.of("a"), second.seen);
+
+ assertThrows(IllegalStateException.class, () ->
composite.error("dead"));
+ assertEquals(List.of("a", "error"), second.seen);
+ }
+}
diff --git a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/pom.xml
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/pom.xml
index 06d1602e00..d6ce406eeb 100644
--- a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/pom.xml
+++ b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/pom.xml
@@ -41,6 +41,11 @@
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>${project.groupId}</groupId>
+ <artifactId>tika-pipes-reporter-commons</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
diff --git
a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/reporter/fs/FileSystemJsonlReporter.java
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/reporter/fs/FileSystemJsonlReporter.java
new file mode 100644
index 0000000000..0d74ce5445
--- /dev/null
+++
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/reporter/fs/FileSystemJsonlReporter.java
@@ -0,0 +1,216 @@
+/*
+ * 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.reporter.fs;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.nio.ByteBuffer;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.time.Instant;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.pipes.api.FetchEmitTuple;
+import org.apache.tika.pipes.api.PipesResult;
+import org.apache.tika.pipes.api.pipesiterator.TotalCountResult;
+import org.apache.tika.pipes.reporters.PipesReporterBase;
+import org.apache.tika.plugins.ExtensionConfig;
+import org.apache.tika.utils.ExceptionUtils;
+
+/**
+ * Append-only per-document audit log: one JSON object per line for every
result
+ * accepted by the includes/excludes filter. The line's {@code id} is the
+ * {@link FetchEmitTuple#getId()} verbatim, so consumers join on it.
+ * <p>
+ * Each line is written and flushed to the OS synchronously in {@link
#report}, so
+ * it survives the driver process dying (not a host crash; there is no fsync).
A
+ * write failure (disk full, etc.) throws from that {@link #report} and every
later
+ * one rather than dropping lines silently.
+ */
+public class FileSystemJsonlReporter extends PipesReporterBase {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(FileSystemJsonlReporter.class);
+
+ private record Line(String id, String status, String category, String
message, long elapsedMs, String timestamp) {
+ }
+
+ private record ErrorLine(String error, String timestamp) {
+ }
+
+ public static FileSystemJsonlReporter build(ExtensionConfig pluginConfig)
throws TikaConfigException, IOException {
+ FileSystemJsonlReporterConfig config =
FileSystemJsonlReporterConfig.load(pluginConfig.json());
+ return new FileSystemJsonlReporter(pluginConfig, config);
+ }
+
+ private final FileSystemJsonlReporterConfig config;
+ private final ObjectMapper mapper = new ObjectMapper();
+ private final BufferedWriter writer;
+ private IOException writerFailure;
+ private boolean closed;
+
+ public FileSystemJsonlReporter(ExtensionConfig pluginConfig,
FileSystemJsonlReporterConfig config) throws TikaConfigException, IOException {
+ super(pluginConfig, config.includes(), config.excludes());
+ this.config = config;
+ if (config.path() == null) {
+ throw new TikaConfigException("must initialize 'path'");
+ }
+ this.writer = open(config);
+ }
+
+ private static BufferedWriter open(FileSystemJsonlReporterConfig config)
throws TikaConfigException, IOException {
+ Path path = config.path();
+ if (Files.isDirectory(path)) {
+ throw new TikaConfigException("'" + path + "' is a directory;
'path' must be a file");
+ }
+ if (path.getParent() != null) {
+ Files.createDirectories(path.getParent());
+ }
+ StandardOpenOption[] options = switch (config.onExists()) {
+ case EXCEPTION -> new
StandardOpenOption[]{StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE};
+ case APPEND -> new StandardOpenOption[]{StandardOpenOption.CREATE,
StandardOpenOption.WRITE, StandardOpenOption.APPEND};
+ case REPLACE -> new
StandardOpenOption[]{StandardOpenOption.CREATE, StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING};
+ };
+ boolean needsNewline = config.onExists() ==
FileSystemJsonlReporterConfig.ON_EXISTS.APPEND && lacksTrailingNewline(path);
+ if (needsNewline) {
+ LOG.warn("'{}' ends in a partial line (a previous run died
mid-write); terminating it at offset {}", path, Files.size(path));
+ }
+ try {
+ // lone surrogates in ids/messages must not kill the log; default
encoder would throw
+ BufferedWriter writer = new BufferedWriter(new
OutputStreamWriter(Files.newOutputStream(path, options),
+ StandardCharsets.UTF_8.newEncoder()
+ .onMalformedInput(CodingErrorAction.REPLACE)
+
.onUnmappableCharacter(CodingErrorAction.REPLACE)));
+ if (needsNewline) {
+ writer.write('\n');
+ }
+ return writer;
+ } catch (FileAlreadyExistsException e) {
+ throw new TikaConfigException("'" + path + "' already exists; set
onExists to APPEND or REPLACE to reuse it", e);
+ }
+ }
+
+ private static boolean lacksTrailingNewline(Path path) throws IOException {
+ if (!Files.isRegularFile(path) || Files.size(path) == 0) {
+ return false;
+ }
+ try (SeekableByteChannel ch = Files.newByteChannel(path,
StandardOpenOption.READ)) {
+ ByteBuffer last = ByteBuffer.allocate(1);
+ ch.position(ch.size() - 1);
+ ch.read(last);
+ return last.get(0) != '\n';
+ } catch (AccessDeniedException e) {
+ // write-only file: can't inspect, so don't require read
permission just for this
+ LOG.warn("can't read '{}' to check for a partial last line;
appending as-is", path);
+ return false;
+ }
+ }
+
+ @Override
+ public void report(FetchEmitTuple t, PipesResult result, long elapsed) {
+ if (!accept(result.status())) {
+ return;
+ }
+ write(new Line(t.getId(), result.status().name(),
result.status().getCategory().name(),
+ truncate(result.message()), elapsed,
Instant.now().toString()), t.getId());
+ }
+
+ private String truncate(String msg) {
+ int max = config.maxMessageLength();
+ if (msg == null || msg.length() <= max) {
+ return msg;
+ }
+ int cut = Character.isHighSurrogate(msg.charAt(max - 1)) ? max - 1 :
max;
+ return msg.substring(0, cut) + "...[truncated " + (msg.length() - cut)
+ " chars]";
+ }
+
+ private synchronized void write(Object line, String id) {
+ if (writerFailure != null) {
+ throw new IllegalStateException("jsonl reporter writer failed
earlier; refusing to drop lines silently", writerFailure);
+ }
+ if (closed) {
+ LOG.warn("jsonl reporter already closed; dropping report for {}",
id);
+ return;
+ }
+ try {
+ // always \n, never the platform separator: jsonl is \n-delimited
+ writer.write(mapper.writeValueAsString(line));
+ writer.write('\n');
+ writer.flush();
+ } catch (IOException e) {
+ LOG.error("jsonl reporter failed writing {}", config.path(), e);
+ writerFailure = e;
+ throw new IllegalStateException("jsonl reporter failed writing " +
config.path(), e);
+ }
+ }
+
+ @Override
+ public void report(TotalCountResult totalCountResult) {
+ //no-op
+ }
+
+ @Override
+ public boolean supportsTotalCount() {
+ return false;
+ }
+
+ @Override
+ public void error(Throwable t) {
+ error(ExceptionUtils.getStackTrace(t));
+ }
+
+ @Override
+ public synchronized void error(String msg) {
+ // close() may never be called after this; get the line on disk now
+ try {
+ write(new ErrorLine(truncate(msg), Instant.now().toString()),
"<error>");
+ } catch (IllegalStateException e) {
+ LOG.warn("couldn't record error in jsonl reporter", e);
+ }
+ try {
+ closeWriter();
+ } catch (IOException e) {
+ LOG.warn("problem closing {}", config.path(), e);
+ }
+ }
+
+ @Override
+ public synchronized void close() throws IOException {
+ closeWriter();
+ if (writerFailure != null) {
+ throw writerFailure;
+ }
+ }
+
+ private void closeWriter() throws IOException {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ writer.close();
+ }
+}
diff --git
a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/reporter/fs/FileSystemJsonlReporterConfig.java
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/reporter/fs/FileSystemJsonlReporterConfig.java
new file mode 100644
index 0000000000..2837e0b307
--- /dev/null
+++
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/reporter/fs/FileSystemJsonlReporterConfig.java
@@ -0,0 +1,48 @@
+/*
+ * 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.reporter.fs;
+
+import java.nio.file.Path;
+import java.util.Set;
+
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.plugins.PluginJson;
+
+public record FileSystemJsonlReporterConfig(Path path, Set<String> includes,
Set<String> excludes, ON_EXISTS onExists, int maxMessageLength) {
+
+ public enum ON_EXISTS {
+ EXCEPTION, APPEND, REPLACE
+ }
+
+ public static final int DEFAULT_MAX_MESSAGE_LENGTH = 10_000;
+
+ public FileSystemJsonlReporterConfig {
+ if (onExists == null) {
+ onExists = ON_EXISTS.EXCEPTION;
+ }
+ if (maxMessageLength < 0) {
+ throw new IllegalArgumentException("maxMessageLength must be >= 0;
0 means the default (" + DEFAULT_MAX_MESSAGE_LENGTH + ")");
+ }
+ if (maxMessageLength == 0) {
+ maxMessageLength = DEFAULT_MAX_MESSAGE_LENGTH;
+ }
+ }
+
+ public static FileSystemJsonlReporterConfig load(final String json) throws
TikaConfigException {
+ return PluginJson.read(json, FileSystemJsonlReporterConfig.class);
+ }
+}
diff --git
a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/reporter/fs/FileSystemJsonlReporterFactory.java
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/reporter/fs/FileSystemJsonlReporterFactory.java
new file mode 100644
index 0000000000..8d80a67166
--- /dev/null
+++
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/reporter/fs/FileSystemJsonlReporterFactory.java
@@ -0,0 +1,41 @@
+/*
+ * 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.reporter.fs;
+
+import java.io.IOException;
+
+import org.pf4j.Extension;
+
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.pipes.api.reporter.PipesReporterFactory;
+import org.apache.tika.plugins.ExtensionConfig;
+
+@Extension
+public class FileSystemJsonlReporterFactory implements PipesReporterFactory {
+
+ public static final String NAME = "file-system-jsonl-reporter";
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ public FileSystemJsonlReporter buildExtension(ExtensionConfig
extensionConfig) throws IOException, TikaConfigException {
+ return FileSystemJsonlReporter.build(extensionConfig);
+ }
+}
diff --git
a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/java/org/apache/tika/pipes/fs/ConfigExamplesTest.java
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/java/org/apache/tika/pipes/fs/ConfigExamplesTest.java
index 041e079e15..5754091fb0 100644
---
a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/java/org/apache/tika/pipes/fs/ConfigExamplesTest.java
+++
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/java/org/apache/tika/pipes/fs/ConfigExamplesTest.java
@@ -16,12 +16,25 @@
*/
package org.apache.tika.pipes.fs;
+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.InputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Paths;
+import java.util.Collections;
+
+import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
import org.apache.tika.pipes.core.testutil.AbstractConfigExamplesTest;
+import org.apache.tika.pipes.reporter.fs.FileSystemJsonlReporterConfig;
+import org.apache.tika.pipes.reporter.fs.FileSystemJsonlReporterFactory;
/**
- * Validates file system fetcher/emitter configuration examples used in
documentation.
+ * Validates file system plugin configuration examples used in documentation.
* <p>
* The JSON configuration examples are stored in {@code
src/test/resources/config-examples/}
* and are included directly in the AsciiDoc documentation via the {@code
include::} directive.
@@ -42,4 +55,31 @@ public class ConfigExamplesTest extends
AbstractConfigExamplesTest {
public void testFileSystemPipelineConfig() throws Exception {
loadAndValidate("file-system-pipeline.json");
}
+
+ @Test
+ public void testFileSystemJsonlReporterConfig() throws Exception {
+ loadAndValidate("file-system-jsonl-reporter.json");
+
+ JsonNode inner =
innerComponent(readExample("file-system-jsonl-reporter.json"),
+ "pipes-reporters", null, "file-system-jsonl-reporter");
+ FileSystemJsonlReporterConfig config =
FileSystemJsonlReporterConfig.load(inner.toString());
+ assertEquals(Paths.get("/var/log/tika/pipes-audit.jsonl"),
config.path());
+ assertEquals(FileSystemJsonlReporterConfig.ON_EXISTS.EXCEPTION,
config.onExists());
+ assertEquals(10000, config.maxMessageLength());
+ assertTrue(config.includes().contains("OOM"));
+ assertNull(config.excludes());
+ }
+
+ // the plugin only resolves by name if the factory made it into pf4j's
index
+ @Test
+ public void testJsonlReporterFactoryIsRegistered() throws Exception {
+ // test-classes carries its own (empty) index that shadows main's, so
scan them all
+ StringBuilder all = new StringBuilder();
+ for (URL url :
Collections.list(getClass().getClassLoader().getResources("META-INF/extensions.idx")))
{
+ try (InputStream is = url.openStream()) {
+ all.append(new String(is.readAllBytes(),
StandardCharsets.UTF_8));
+ }
+ }
+
assertTrue(all.toString().contains(FileSystemJsonlReporterFactory.class.getName()),
all.toString());
+ }
}
diff --git
a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/java/org/apache/tika/pipes/reporter/fs/FileSystemJsonlReporterTest.java
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/java/org/apache/tika/pipes/reporter/fs/FileSystemJsonlReporterTest.java
new file mode 100644
index 0000000000..a52621196c
--- /dev/null
+++
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/java/org/apache/tika/pipes/reporter/fs/FileSystemJsonlReporterTest.java
@@ -0,0 +1,257 @@
+/*
+ * 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.reporter.fs;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.pipes.api.FetchEmitTuple;
+import org.apache.tika.pipes.api.PipesResult;
+import org.apache.tika.pipes.api.emitter.EmitKey;
+import org.apache.tika.pipes.api.fetcher.FetchKey;
+import org.apache.tika.plugins.ExtensionConfig;
+
+public class FileSystemJsonlReporterTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static FileSystemJsonlReporter build(Path path, Map<String,
Object> extra) throws Exception {
+ Map<String, Object> cfg = new LinkedHashMap<>();
+ cfg.put("path", path.toAbsolutePath().toString());
+ cfg.putAll(extra);
+ return build(MAPPER.writeValueAsString(cfg));
+ }
+
+ private static FileSystemJsonlReporter build(String json) throws Exception
{
+ return new FileSystemJsonlReporterFactory().buildExtension(new
ExtensionConfig("test", FileSystemJsonlReporterFactory.NAME, json));
+ }
+
+ private static void report(FileSystemJsonlReporter r, String id,
PipesResult.RESULT_STATUS status, String msg) {
+ r.report(new FetchEmitTuple(id, new FetchKey("f", id), new
EmitKey("e", id)), new PipesResult(status, msg), 7);
+ }
+
+ private static List<Map<String, Object>> lines(Path path) throws
IOException {
+ List<Map<String, Object>> ret = new ArrayList<>();
+ for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) {
+ ret.add(MAPPER.readValue(line, Map.class));
+ }
+ return ret;
+ }
+
+ private static List<Object> ids(Path path) throws IOException {
+ return lines(path).stream().map(m -> m.get("id")).toList();
+ }
+
+ @Test
+ public void testIncludesAndFields(@TempDir Path tmp) throws Exception {
+ Path path = tmp.resolve("audit.jsonl");
+ try (FileSystemJsonlReporter r = build(path, Map.of("includes",
List.of("OOM", "TIMEOUT")))) {
+ report(r, "a/b.pdf", PipesResult.RESULT_STATUS.OOM, "boom");
+ report(r, "c.doc", PipesResult.RESULT_STATUS.PARSE_SUCCESS, null);
+ report(r, "d.doc", PipesResult.RESULT_STATUS.TIMEOUT, null);
+ }
+ List<Map<String, Object>> lines = lines(path);
+ assertEquals(2, lines.size());
+ Map<String, Object> first = lines.get(0);
+ assertEquals("a/b.pdf", first.get("id"));
+ assertEquals("OOM", first.get("status"));
+ assertEquals("PROCESS_CRASH", first.get("category"));
+ assertEquals("boom", first.get("message"));
+ assertEquals(7, first.get("elapsedMs"));
+ assertTrue(first.get("timestamp").toString().endsWith("Z"));
+ assertEquals(Set.of("id", "status", "category", "message",
"elapsedMs", "timestamp"), first.keySet());
+ assertEquals("d.doc", lines.get(1).get("id"));
+ }
+
+ @Test
+ public void testExcludes(@TempDir Path tmp) throws Exception {
+ Path path = tmp.resolve("audit.jsonl");
+ try (FileSystemJsonlReporter r = build(path, Map.of("excludes",
List.of("PARSE_SUCCESS")))) {
+ report(r, "a", PipesResult.RESULT_STATUS.PARSE_SUCCESS, null);
+ report(r, "b", PipesResult.RESULT_STATUS.OOM, null);
+ }
+ assertEquals(List.of("b"), ids(path));
+ }
+
+ @Test
+ public void testConcurrentReportsAllLand(@TempDir Path tmp) throws
Exception {
+ Path path = tmp.resolve("audit.jsonl");
+ int threads = 8;
+ int perThread = 500;
+ Set<String> expected = new HashSet<>();
+ try (FileSystemJsonlReporter r = build(path, Map.of())) {
+ ExecutorService ex = Executors.newFixedThreadPool(threads);
+ List<Future<?>> futures = new ArrayList<>();
+ for (int t = 0; t < threads; t++) {
+ final int tid = t;
+ futures.add(ex.submit(() -> {
+ for (int i = 0; i < perThread; i++) {
+ report(r, tid + "/" + i,
PipesResult.RESULT_STATUS.PARSE_SUCCESS, null);
+ }
+ }));
+ for (int i = 0; i < perThread; i++) {
+ expected.add(tid + "/" + i);
+ }
+ }
+ for (Future<?> f : futures) {
+ f.get();
+ }
+ ex.shutdown();
+ }
+ List<Object> ids = ids(path);
+ assertEquals(threads * perThread, ids.size());
+ assertEquals(expected, new HashSet<>(ids));
+ }
+
+ @Test
+ public void testOnExists(@TempDir Path tmp) throws Exception {
+ Path path = tmp.resolve("audit.jsonl");
+ try (FileSystemJsonlReporter r = build(path, Map.of())) {
+ report(r, "first", PipesResult.RESULT_STATUS.OOM, null);
+ }
+ assertThrows(TikaConfigException.class, () -> build(path, Map.of()));
+ assertThrows(TikaConfigException.class, () -> build(path,
Map.of("onExists", "EXCEPTION")));
+ assertEquals(List.of("first"), ids(path));
+
+ try (FileSystemJsonlReporter r = build(path, Map.of("onExists",
"APPEND"))) {
+ report(r, "second", PipesResult.RESULT_STATUS.OOM, null);
+ }
+ assertEquals(List.of("first", "second"), ids(path));
+
+ try (FileSystemJsonlReporter r = build(path, Map.of("onExists",
"REPLACE"))) {
+ report(r, "third", PipesResult.RESULT_STATUS.OOM, null);
+ }
+ assertEquals(List.of("third"), ids(path));
+ }
+
+ @Test
+ public void testAppendAfterPartialLine(@TempDir Path tmp) throws Exception
{
+ Path path = tmp.resolve("audit.jsonl");
+ Files.writeString(path, "{\"id\":\"cut", StandardCharsets.UTF_8);
+ try (FileSystemJsonlReporter r = build(path, Map.of("onExists",
"APPEND"))) {
+ report(r, "next", PipesResult.RESULT_STATUS.OOM, null);
+ }
+ List<String> raw = Files.readAllLines(path, StandardCharsets.UTF_8);
+ assertEquals(2, raw.size());
+ assertEquals("next", MAPPER.readValue(raw.get(1),
Map.class).get("id"));
+ }
+
+ @Test
+ public void testMessageCap(@TempDir Path tmp) throws Exception {
+ Path path = tmp.resolve("audit.jsonl");
+ try (FileSystemJsonlReporter r = build(path,
Map.of("maxMessageLength", 10))) {
+ report(r, "x", PipesResult.RESULT_STATUS.OOM, "0123456789abcdef");
+ report(r, "y", PipesResult.RESULT_STATUS.OOM, "012345678😀ab");
+ }
+ List<Map<String, Object>> lines = lines(path);
+ assertEquals("0123456789...[truncated 6 chars]",
lines.get(0).get("message"));
+ // cut lands on a surrogate pair: back off one so the pair isn't split
+ assertEquals("012345678...[truncated 4 chars]",
lines.get(1).get("message"));
+ }
+
+ @Test
+ public void testLoneSurrogateDoesNotKillWriter(@TempDir Path tmp) throws
Exception {
+ Path path = tmp.resolve("audit.jsonl");
+ try (FileSystemJsonlReporter r = build(path, Map.of())) {
+ report(r, "bad" + (char) 0xD83D, PipesResult.RESULT_STATUS.OOM,
(char) 0xDE00 + " lone low");
+ report(r, "after", PipesResult.RESULT_STATUS.OOM, null);
+ }
+ List<Map<String, Object>> lines = lines(path);
+ assertEquals(2, lines.size());
+ assertEquals("after", lines.get(1).get("id"));
+ }
+
+ @Test
+ public void testMultilineMessageStaysOneLine(@TempDir Path tmp) throws
Exception {
+ Path path = tmp.resolve("audit.jsonl");
+ try (FileSystemJsonlReporter r = build(path, Map.of())) {
+ report(r, "x", PipesResult.RESULT_STATUS.OOM, "line1\nline2\r\n
line3");
+ }
+ assertEquals(1, Files.readAllLines(path).size());
+ assertEquals("line1\nline2\r\n line3",
lines(path).get(0).get("message"));
+ }
+
+ @Test
+ public void testErrorFlushesWithoutClose(@TempDir Path tmp) throws
Exception {
+ Path path = tmp.resolve("audit.jsonl");
+ FileSystemJsonlReporter r = build(path, Map.of());
+ report(r, "x", PipesResult.RESULT_STATUS.OOM, null);
+ r.error(new RuntimeException("fatal"));
+ List<Map<String, Object>> lines = lines(path);
+ assertEquals(2, lines.size());
+ assertEquals(Set.of("error", "timestamp"), lines.get(1).keySet());
+ assertTrue(lines.get(1).get("error").toString().contains("fatal"));
+ // late reports after error/close are dropped, not thrown
+ assertDoesNotThrow(() -> report(r, "y", PipesResult.RESULT_STATUS.OOM,
null));
+ assertDoesNotThrow(r::close);
+ assertEquals(2, lines(path).size());
+ }
+
+ @Test
+ @EnabledOnOs(OS.LINUX)
+ public void testWriteFailureIsLoud() throws Exception {
+ Path devFull = Path.of("/dev/full");
+ try (FileSystemJsonlReporter r = build(devFull, Map.of("onExists",
"APPEND"))) {
+ assertThrows(IllegalStateException.class, () -> report(r, "x",
PipesResult.RESULT_STATUS.OOM, null));
+ assertThrows(IllegalStateException.class, () -> report(r, "y",
PipesResult.RESULT_STATUS.OOM, null));
+ assertThrows(IOException.class, r::close);
+ } catch (IOException expected) {
+ //try-with-resources close
+ }
+ }
+
+ @Test
+ public void testConfigErrors(@TempDir Path tmp) throws Exception {
+ assertThrows(TikaConfigException.class, () -> build("{}"));
+ assertThrows(TikaConfigException.class, () ->
build(tmp.resolve("a.jsonl"), Map.of("maxMessageLength", -1)));
+ assertThrows(TikaConfigException.class, () -> build(tmp, Map.of()));
+ assertFalse(Files.exists(tmp.resolve("a.jsonl")));
+ }
+
+ @Test
+ public void testCreatesParentDirs(@TempDir Path tmp) throws Exception {
+ Path path = tmp.resolve("a/b/audit.jsonl");
+ try (FileSystemJsonlReporter r = build(path, Map.of())) {
+ report(r, "x", PipesResult.RESULT_STATUS.OOM, null);
+ }
+ assertEquals(1, lines(path).size());
+ }
+}
diff --git
a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/resources/config-examples/file-system-jsonl-reporter.json
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/resources/config-examples/file-system-jsonl-reporter.json
new file mode 100644
index 0000000000..b720101362
--- /dev/null
+++
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/resources/config-examples/file-system-jsonl-reporter.json
@@ -0,0 +1,10 @@
+{
+ "pipes-reporters": {
+ "file-system-jsonl-reporter": {
+ "path": "/var/log/tika/pipes-audit.jsonl",
+ "includes": ["OOM", "TIMEOUT", "UNSPECIFIED_CRASH",
"PAYLOAD_LIMIT_EXCEEDED", "EMIT_EXCEPTION", "FETCH_EXCEPTION"],
+ "onExists": "EXCEPTION",
+ "maxMessageLength": 10000
+ }
+ }
+}