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 08c70481ba TIKA-4847: tika-eval joins the pipes jsonl crash ledger and
run-info … (#3083)
08c70481ba is described below
commit 08c70481babe08a195888f4c5dbe394ee04cc514
Author: Tim Allison <[email protected]>
AuthorDate: Thu Aug 27 17:18:29 2026 -0400
TIKA-4847: tika-eval joins the pipes jsonl crash ledger and run-info …
(#3083)
---
.skills/devs/tika-eval-compare/SKILL.md | 28 ++
.skills/devs/tika-eval-compare/run-batch.sh | 179 ++++++++++++
CHANGES.txt | 10 +
.../integration-testing/tika-eval-regression.adoc | 60 +++-
.../org/apache/tika/eval/app/ExtractComparer.java | 16 +-
.../tika/eval/app/ExtractComparerRunner.java | 48 +++-
.../apache/tika/eval/app/ExtractProfileRunner.java | 34 ++-
.../org/apache/tika/eval/app/ExtractProfiler.java | 10 +-
.../org/apache/tika/eval/app/ProfilerBase.java | 15 +
.../java/org/apache/tika/eval/app/RunInfo.java | 314 +++++++++++++++++++++
.../java/org/apache/tika/eval/app/db/Cols.java | 4 +
.../org/apache/tika/eval/app/io/PipesReport.java | 127 +++++++++
.../eval/app/reports/MarkdownSummaryWriter.java | 57 +++-
.../org/apache/tika/eval/app/reports/Report.java | 12 +-
.../tika/eval/app/reports/ResultsReporter.java | 14 +-
.../src/main/resources/comparison-reports.xml | 115 ++++++++
.../src/main/resources/profile-reports.xml | 59 +++-
.../apache/tika/eval/app/ComparerBatchTest.java | 144 ++++++++++
.../tika/eval/app/ComparerOneSidedLedgerTest.java | 103 +++++++
.../apache/tika/eval/app/ProfilerBatchTest.java | 37 ++-
.../tika/eval/app/ProfilerDiscoveryTest.java | 73 +++++
.../java/org/apache/tika/eval/app/RunInfoTest.java | 105 +++++++
.../apache/tika/eval/app/io/PipesReportTest.java | 92 ++++++
.../extractsB/.run-info/crashes-run-b1.jsonl | 3 +
.../extractsB/.run-info/run-info-run-b1.json | 4 +
.../test-dirs/pipes-reports/crashes-run-a1.jsonl | 6 +
.../test-dirs/pipes-reports/crashes-run-b1.jsonl | 3 +
.../test-dirs/pipes-reports/run-info-run-a1.json | 6 +
.../test-dirs/pipes-reports/run-info-run-b1.json | 4 +
29 files changed, 1661 insertions(+), 21 deletions(-)
diff --git a/.skills/devs/tika-eval-compare/SKILL.md
b/.skills/devs/tika-eval-compare/SKILL.md
index e1206be371..95c69fc479 100644
--- a/.skills/devs/tika-eval-compare/SKILL.md
+++ b/.skills/devs/tika-eval-compare/SKILL.md
@@ -91,6 +91,23 @@ Each run walks the input directory recursively and writes one
equivalent to `tika-app -J`). The directory structure mirrors
the input.
+### Provenance + crash ledger (preferred)
+
+`run-batch.sh` (next to this file) wraps the same invocation and records what
+ran, so an extract set can be tied to a build after the fact and a crashed
+file is distinguishable from one that parsed to nothing:
+
+```bash
+.skills/devs/tika-eval-compare/run-batch.sh --app <before> --input <input-dir>
--extracts <extracts-a-dir> --note baseline
+.skills/devs/tika-eval-compare/run-batch.sh --app <after> --input <input-dir>
--extracts <extracts-b-dir> --note candidate [--config cfg.json]
+```
+
+It writes `run-info-<run.id>.json` and, when the app's file-system plugin
+ships the jsonl reporter (TIKA-4846), `crashes-<run.id>.jsonl` into
+`<extracts>/.run-info/`; Compare picks them up from there by default
+(`-ra/-rb`, `-pa/-pb` override). A baseline without the reporter gets
+run-info but no ledger. Needs python3.
+
### Notes
- Do NOT pass `-n <N>` as a trailing argument — it confuses the
@@ -123,6 +140,17 @@ java -jar <tika-eval>/tika-eval-app-*.jar Compare \
| `-rd` | Reports output directory (default: `reports`) |
| `-z` | Gzip the H2 db (`<db>.mv.db.gz`) after Compare for transfer; requires
`-d` (no-op + warning for a temp db). Combine with `-r` to package both. |
| `-n` | Number of worker threads |
+| `-pa`/`-pb` | jsonl ledger for A/B (default:
`<extracts>/.run-info/crashes-*.jsonl`); fills `containers.pipes_status_a/b` |
+| `-ra`/`-rb` | run-info json for A/B (default:
`<extracts>/.run-info/run-info-*.json`); lands in `run_info_a/b`. Refused
unless the matching ledger is named `crashes-<run.id>.jsonl`; discovery refuses
a `.run-info` holding more than one run-info or ledger |
+
+With `-pa`/`-pb`, `summary.md` and
`exceptions/extract_exceptions_by_pipes_status_*.xlsx`
+split `NO_EXTRACT_FILE` into `CRASH` (OOM/TIMEOUT/UNSPECIFIED_CRASH), any other
+recorded status as-is, `NO_PIPES_RECORD` (ledger has no line: the batch
recorded
+no failure), `BATCH_WITHOUT_LEDGER`, and `NO_PIPES_REPORT_SUPPLIED`. A crash
+status *with* an extract present is a success whose status was lost — listed
+separately, not a failure. `run_info_a/b.pipes_report.joined` says how many
+containers matched a ledger row; zero with a non-empty ledger means the wrong
+ledger, or a crawl without `-i` (which never sees files that crashed).
## Step 3 — Review Results
diff --git a/.skills/devs/tika-eval-compare/run-batch.sh
b/.skills/devs/tika-eval-compare/run-batch.sh
new file mode 100755
index 0000000000..bbaefbbeb4
--- /dev/null
+++ b/.skills/devs/tika-eval-compare/run-batch.sh
@@ -0,0 +1,179 @@
+#!/usr/bin/env bash
+# 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.
+set -euo pipefail
+
+usage() {
+ cat >&2 <<'EOF'
+Runs a tika-app batch extraction with provenance and a crash ledger that
+tika-eval can join back in (Profile --pipesReport/--runInfo,
+Compare --pipesReportA/B --runInfoA/B). Needs python3.
+
+ run-batch.sh --app <tika-app dir or jar> --input <dir> --extracts <dir> \
+ [--config <tika-config.json>] [--note "text"] [-- <extra
tika-app args>]
+
+Writes to <extracts>/.run-info/ (tika-eval skips that dir when crawling and
picks these up by default):
+ run-info-<run.id>.json what ran (jar/lib/plugin sha256s, config sha256,
jvm, host, time, exit code)
+ crashes-<run.id>.jsonl one line per failed result, from the
file-system-jsonl-reporter
+ (only when the tika-app's file-system plugin ships
it, TIKA-4846)
+
+Secrets: JAVA_OPTS values that look like passwords/tokens are masked in
run-info; the
+tika-config you pass is recorded by path and sha256 only, never copied into
the extracts dir.
+EOF
+ exit 2
+}
+
+APP=""; INPUT=""; EXTRACTS=""; CONFIG=""; NOTE=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --app) APP="$2"; shift 2;;
+ --input) INPUT="$2"; shift 2;;
+ --extracts) EXTRACTS="$2"; shift 2;;
+ --config) CONFIG="$2"; shift 2;;
+ --note) NOTE="$2"; shift 2;;
+ -h|--help) usage;;
+ --) shift; break;;
+ *) echo "unknown arg: $1" >&2; usage;;
+ esac
+done
+[[ -n "$APP" && -n "$INPUT" && -n "$EXTRACTS" ]] || usage
+command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; }
+
+if [[ -d "$APP" ]]; then
+ jars=("$APP"/tika-app-*.jar)
+ [[ ${#jars[@]} -eq 1 && -f "${jars[0]}" ]] || { echo "expected exactly one
tika-app-*.jar under $APP, found: ${jars[*]}" >&2; exit 2; }
+ JAR="${jars[0]}"; APP_DIR="$APP"
+else
+ JAR="$APP"; APP_DIR=$(dirname "$JAR")
+fi
+[[ -f "$JAR" ]] || { echo "no tika-app jar at $JAR" >&2; exit 2; }
+
+abs() { python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$1";
}
+sha() { python3 -c 'import hashlib,sys;
print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$1"; }
+# prints the manifest value or nothing; exit 1 when absent so || chains work
+manifest() {
+ local v
+ v=$(unzip -p "$JAR" META-INF/MANIFEST.MF 2>/dev/null | tr -d '\r' | awk -F':
' -v k="$1" '$1==k{print $2}')
+ [[ -n "$v" ]] && echo "$v"
+}
+
+RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$(head -c4 /dev/urandom | od -An -tx1 | tr
-d ' \n')"
+mkdir -p "$EXTRACTS"
+OUT_DIR="$(abs "$EXTRACTS")/.run-info"
+mkdir -p "$OUT_DIR"
+RUN_INFO="$OUT_DIR/run-info-$RUN_ID.json"
+LEDGER="$OUT_DIR/crashes-$RUN_ID.jsonl"
+
+TIKA_VERSION=$(manifest Implementation-Version || basename "$JAR" | sed -E
's/^tika-app-(.*)\.jar$/\1/')
+GIT_COMMIT=$(manifest Git-Commit || manifest Implementation-Build || true)
+
+LIB_SHA=""
+if [[ -d "$APP_DIR/lib" ]]; then
+ LIB_SHA=$(cd "$APP_DIR/lib" && for f in *.jar; do [[ -f "$f" ]] && echo "$f
$(sha "$f")"; done | python3 -c 'import hashlib,sys;
print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')
+fi
+
+# the ledger needs the jsonl reporter inside the file-system plugin
(TIKA-4846); look for the class, not a version
+USE_LEDGER=false
+if [[ -d "$APP_DIR/plugins" ]] && python3 - "$APP_DIR/plugins" <<'PY'
+import io, sys, zipfile, glob, os
+for z in glob.glob(os.path.join(sys.argv[1], "tika-pipes-file-system*.zip")):
+ with zipfile.ZipFile(z) as outer:
+ for n in outer.namelist():
+ if n.endswith(".jar") and "tika-pipes-file-system" in n:
+ with zipfile.ZipFile(io.BytesIO(outer.read(n))) as inner:
+ if any(m.endswith("FileSystemJsonlReporter.class") for m
in inner.namelist()):
+ sys.exit(0)
+sys.exit(1)
+PY
+then USE_LEDGER=true; else echo "no file-system-jsonl-reporter in
$APP_DIR/plugins; recording run-info without a crash ledger" >&2; fi
+
+# merged config lives outside the extracts dir: the user's config may carry
fetcher/emitter secrets
+TMP_DIR=$(mktemp -d); trap 'rm -rf "$TMP_DIR"' EXIT
+EFFECTIVE_CONFIG="$CONFIG"
+if $USE_LEDGER; then
+ EFFECTIVE_CONFIG="$TMP_DIR/tika-config-$RUN_ID.json"
+ python3 - "$CONFIG" "$EFFECTIVE_CONFIG" "$LEDGER" <<'PY'
+import json, sys
+src, dst, ledger = sys.argv[1:]
+cfg = json.load(open(src)) if src else {}
+reporters = cfg.setdefault("pipes-reporters", {})
+if not isinstance(reporters, dict):
+ sys.exit("pipes-reporters in %s is not an object" % src)
+if "file-system-jsonl-reporter" in reporters:
+ sys.exit("%s already configures file-system-jsonl-reporter; remove it or
run without --config" % src)
+# every non-success status: anything here leaves no extract behind
+reporters["file-system-jsonl-reporter"] = {
+ "path": ledger,
+ "includes": ["OOM", "TIMEOUT", "UNSPECIFIED_CRASH",
"FAILED_TO_INITIALIZE", "FETCHER_INITIALIZATION_EXCEPTION",
+ "EMITTER_INITIALIZATION_EXCEPTION",
"CLIENT_UNAVAILABLE_WITHIN_MS", "FETCH_EXCEPTION", "EMIT_EXCEPTION",
+ "FETCHER_NOT_FOUND", "EMITTER_NOT_FOUND",
"PAYLOAD_LIMIT_EXCEEDED"],
+ "onExists": "EXCEPTION",
+ "maxMessageLength": 4096,
+}
+json.dump(cfg, open(dst, "w"), indent=2)
+PY
+fi
+
+CONFIG_SHA=""; CONFIG_ABS=""
+if [[ -n "$CONFIG" ]]; then CONFIG_SHA=$(sha "$CONFIG"); CONFIG_ABS=$(abs
"$CONFIG"); fi
+JVM_ARGS="${JAVA_OPTS:-}"
+
+# values go to python via the environment; no shell-into-json quoting
+RB_RUN_ID="$RUN_ID" RB_NOTE="$NOTE" RB_START="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
+RB_JAR="$(abs "$JAR")" RB_JAR_SHA="$(sha "$JAR")" RB_VERSION="$TIKA_VERSION"
RB_COMMIT="$GIT_COMMIT" \
+RB_LIB_SHA="$LIB_SHA" RB_PLUGINS_DIR="$APP_DIR/plugins"
RB_CONFIG="$CONFIG_ABS" RB_CONFIG_SHA="$CONFIG_SHA" \
+RB_INPUT="$(abs "$INPUT")" RB_EXTRACTS="$(abs "$EXTRACTS")" \
+RB_JAVA="$(java -version 2>&1 | head -1)" RB_JVM_ARGS="$JVM_ARGS"
RB_LEDGER="$( $USE_LEDGER && echo "$LEDGER" || true )" \
+python3 - "$RUN_INFO" <<'PY'
+import glob, hashlib, json, os, socket, sys
+e = os.environ.get
+plugins = {}
+for z in sorted(glob.glob(os.path.join(e("RB_PLUGINS_DIR", ""), "*.zip"))):
+ plugins[os.path.basename(z)] = hashlib.sha256(open(z,
"rb").read()).hexdigest()
+tika = {"app_path": e("RB_JAR"), "app_sha256": e("RB_JAR_SHA"), "version":
e("RB_VERSION"), "lib_sha256": e("RB_LIB_SHA"), "plugins": plugins}
+if e("RB_COMMIT"):
+ tika["git_commit"] = e("RB_COMMIT")
+json.dump({
+ "run": {"id": e("RB_RUN_ID"), "note": e("RB_NOTE"), "start": e("RB_START"),
"host": socket.gethostname(), "user": e("USER", "")},
+ "tika": tika,
+ "config": {"path": e("RB_CONFIG"), "sha256": e("RB_CONFIG_SHA")},
+ "input": {"path": e("RB_INPUT")},
+ "extracts": {"path": e("RB_EXTRACTS")},
+ "jvm": {"version": e("RB_JAVA"), "args": e("RB_JVM_ARGS", "")},
+ "ledger": {"path": e("RB_LEDGER")},
+}, open(sys.argv[1], "w"), indent=2)
+PY
+echo "run.id=$RUN_ID run-info=$RUN_INFO" >&2
+
+set +e
+if [[ -n "$EFFECTIVE_CONFIG" ]]; then
+ # shellcheck disable=SC2086 # JAVA_OPTS is word-split on purpose
+ java $JVM_ARGS -jar "$JAR" --config="$EFFECTIVE_CONFIG" "$@" "$INPUT"
"$EXTRACTS"
+else
+ # shellcheck disable=SC2086
+ java $JVM_ARGS -jar "$JAR" "$@" "$INPUT" "$EXTRACTS"
+fi
+RC=$?
+set -e
+
+python3 - "$RUN_INFO" "$RC" <<'PY'
+import json, sys, datetime
+p, rc = sys.argv[1], int(sys.argv[2])
+d = json.load(open(p))
+d["run"]["end"] =
datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+d["run"]["exit_code"] = rc
+json.dump(d, open(p, "w"), indent=2)
+PY
+exit $RC
diff --git a/CHANGES.txt b/CHANGES.txt
index 4d7573febc..42be53d9c8 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,15 @@
Release 4.1.0 - unreleased
+ * tika-eval: Profile/Compare accept the batch run's jsonl crash ledger
+ (--pipesReport, -pa/-pb) and a run-info json (--runInfo, -ra/-rb), and
+ read both from <extracts>/.run-info/ by default (refusing an ambiguous
+ dir). containers gains pipes_status/pipes_message; a new run_info table
+ records eval and batch provenance; reports and
+ summary.md classify NO_EXTRACT_FILE by ledger status (CRASH, the raw
+ status, NO_PIPES_RECORD, BATCH_WITHOUT_LEDGER, NO_PIPES_REPORT_SUPPLIED).
+ Report on a db from an earlier tika-eval skips the reports it cannot run
+ instead of aborting (TIKA-4847).
+
* New file-system-jsonl-reporter pipes reporter (TIKA-4846).
* Stop spooling OLE2 objects whose header over-reserves BAT capacity
diff --git
a/docs/modules/ROOT/pages/advanced/integration-testing/tika-eval-regression.adoc
b/docs/modules/ROOT/pages/advanced/integration-testing/tika-eval-regression.adoc
index abbe57f4f3..21a67d191a 100644
---
a/docs/modules/ROOT/pages/advanced/integration-testing/tika-eval-regression.adoc
+++
b/docs/modules/ROOT/pages/advanced/integration-testing/tika-eval-regression.adoc
@@ -219,6 +219,8 @@ Options:
both the reports and the db.
* `-n` / `--numWorkers` — comparison worker count.
* `-c` / `--config` — optional tika-eval JSON config.
+* `-pa`/`-pb`, `-ra`/`-rb` — crash ledger and run-info per side; see
+ <<Joining the batch crash ledger and run provenance>>
== Step 5: read the reports
@@ -238,6 +240,60 @@ The reports directory contains subdirectories:
Open the `.xlsx` files directly, or query the H2 database for custom counts and
joins (see <<h2>>).
+== Joining the batch crash ledger and run provenance
+
+A crash result (`OOM`, `TIMEOUT`, `UNSPECIFIED_CRASH`) leaves nothing in the
+extract set, so tika-eval alone reports it as `NO_EXTRACT_FILE` -- the same as
+a file that parsed to nothing. Two inputs close that gap:
+
+* the per-document JSONL written during the batch run by the file-system
+ plugin's `file-system-jsonl-reporter` (TIKA-4846; see
+ xref:pipes/plugins/filesystem.adoc[]) -- `--pipesReport` / `-pr` on Profile,
+ `--pipesReportA`/`-pa` and `-pb` on Compare
+* a `run-info.json` describing what ran (`--runInfo` / `-ri`; `-ra`/`-rb` on
Compare)
+
+The reporter line's `id` is the source-relative path (filesystem iterator),
which is
+also `containers.file_path`; both are normalized to `/` at the join. The
result lands in
+`containers.pipes_status` / `pipes_message` (`_a`/`_b` on Compare), and the
reports
+reclassify extract-file problems:
+
+[cols="1,3"]
+|===
+|Classification |Meaning
+
+|`CRASH` |`OOM`, `TIMEOUT` or `UNSPECIFIED_CRASH` recorded for this file. In
shared-server mode a crash takes every in-flight request down with it; those
are not yet distinguishable from the file that caused it, so retry a `CRASH`
once, alone, before blaming the file.
+|any other status |Passed through as recorded (`FETCH_EXCEPTION`,
`PAYLOAD_LIMIT_EXCEEDED`, ...)
+|`NO_PIPES_RECORD` |A ledger was supplied but has no line for this file: the
batch recorded no failure for it. The ledger normally lists failures only, so
for a container with an extract this is the expected value.
+|`BATCH_WITHOUT_LEDGER` |A run-info was supplied but the batch wrote no ledger
(tika-app without the reporter)
+|`NO_PIPES_REPORT_SUPPLIED` |Neither given
+|===
+
+The classification is materialized once per container by the reports'
before-SQL
+into `pipes_class` (`pipes_class_a`/`pipes_class_b`); custom queries can join
it.
+A crash status with an extract present is a success whose status was lost (the
+document emitted before the worker died); it is listed in
+`exceptions/crash_status_extract_present*.xlsx`, not counted as a failure.
+
+`run-info.json` is loaded generically: every key is flattened with dots under
+`batch.` into the `run_info` table (`run_info_a`/`run_info_b` on Compare),
alongside
+`eval.*` (tika-eval version, jar sha256, host, user, args, start/end),
`extracts.*`
+(count and a sha256 fingerprint of sorted name+size) and `pipes_report.*`
(path, row
+count, and `joined` -- how many containers matched a ledger row; zero with a
non-empty
+ledger means the wrong ledger, or a crawl without `-i`, which never sees files
that
+crashed). Password-like values in the jdbc string, arguments and config are
masked, since
+these tables travel with the reports. If `run-info.json` carries `run.id`, the
ledger must
+be named `crashes-<run.id>.jsonl`, or tika-eval refuses to start -- rerunning
a batch into
+the same extracts dir is otherwise invisible. `summary.md` opens with the
`run_info` tables.
+
+The batch-side script that produces both files,
`.skills/devs/tika-eval-compare/run-batch.sh`,
+writes them to `<extracts>/.run-info/`. tika-eval skips that directory when
crawling and
+excludes it from the fingerprint, and uses its contents by default when the
flags above are
+absent -- refusing if it holds more than one run-info or more than one ledger,
since that
+means the extracts dir was written by more than one run.
+
+`Report` on a database written by an earlier tika-eval (no `pipes_status`
columns, no
+`run_info`) skips the reports and summary sections that need them and logs a
warning for each.
+
== Tips
* *Keep the digester identical between A and B.* tika-eval uses the
@@ -358,7 +414,9 @@ Key tables: `profiles_a`/`profiles_b` (one row per
extracted file: `file_name`,
`mime_id`, `length`, …), `contents_a`/`contents_b` (text profile: `oov`,
`languageness`, `num_tokens`, `lang_id_1`, `num_replacement` (U+FFFD count),
`num_non_ascii`, …), `content_comparisons`
-(`dice_coefficient`, `overlap`), `mimes`, `containers`. *A and B are paired by
+(`dice_coefficient`, `overlap`), `mimes`, `containers` (`pipes_status_a/b`,
+`pipes_message_a/b` from the crash ledger),
`run_info`/`run_info_a`/`run_info_b`
+(key/value provenance). *A and B are paired by
`id`* — the same row `id` is the same file in both runs (this is how the
built-in
reports join: `join profiles_b pb on pa.id = pb.id`). Always join on `id`.
diff --git
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractComparer.java
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractComparer.java
index 3e7cfcbfc0..80523c0a01 100644
---
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractComparer.java
+++
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractComparer.java
@@ -38,6 +38,7 @@ import org.apache.tika.eval.app.db.TableInfo;
import org.apache.tika.eval.app.io.ExtractReader;
import org.apache.tika.eval.app.io.ExtractReaderException;
import org.apache.tika.eval.app.io.IDBWriter;
+import org.apache.tika.eval.app.io.PipesReport;
import org.apache.tika.eval.core.textstats.BasicTokenCountStatsCalculator;
import org.apache.tika.eval.core.tokens.ContrastStatistics;
import org.apache.tika.eval.core.tokens.TokenContraster;
@@ -57,7 +58,9 @@ public class ExtractComparer extends ProfilerBase {
public static TableInfo COMPARISON_CONTAINERS =
new TableInfo("containers", new ColInfo(Cols.CONTAINER_ID,
Types.INTEGER, "PRIMARY KEY"), new ColInfo(Cols.FILE_PATH, Types.VARCHAR,
FILE_PATH_MAX_LEN),
new ColInfo(Cols.FILE_EXTENSION, Types.VARCHAR, 12), new
ColInfo(Cols.LENGTH, Types.BIGINT), new ColInfo(Cols.EXTRACT_FILE_LENGTH_A,
Types.BIGINT),
- new ColInfo(Cols.EXTRACT_FILE_LENGTH_B, Types.BIGINT));
+ new ColInfo(Cols.EXTRACT_FILE_LENGTH_B, Types.BIGINT),
+ new ColInfo(Cols.PIPES_STATUS_A, Types.VARCHAR,
PIPES_STATUS_MAX_LEN), new ColInfo(Cols.PIPES_MESSAGE_A, Types.VARCHAR,
PIPES_MESSAGE_MAX_LEN),
+ new ColInfo(Cols.PIPES_STATUS_B, Types.VARCHAR,
PIPES_STATUS_MAX_LEN), new ColInfo(Cols.PIPES_MESSAGE_B, Types.VARCHAR,
PIPES_MESSAGE_MAX_LEN));
public static TableInfo CONTENT_COMPARISONS =
new TableInfo("content_comparisons", new ColInfo(Cols.ID,
Types.INTEGER, "PRIMARY KEY"), new ColInfo(Cols.TOP_10_UNIQUE_TOKEN_DIFFS_A,
Types.VARCHAR, 1024),
new ColInfo(Cols.TOP_10_UNIQUE_TOKEN_DIFFS_B,
Types.VARCHAR, 1024), new ColInfo(Cols.TOP_10_MORE_IN_A, Types.VARCHAR, 1024),
@@ -87,13 +90,22 @@ public class ExtractComparer extends ProfilerBase {
private final Path extractsB;
private final TokenContraster tokenContraster = new TokenContraster();
private final ExtractReader extractReader;
+ private final PipesReport pipesReportA;
+ private final PipesReport pipesReportB;
public ExtractComparer(Path inputDir, Path extractsA, Path extractsB,
ExtractReader extractReader, IDBWriter writer) {
+ this(inputDir, extractsA, extractsB, extractReader, writer, null,
null);
+ }
+
+ public ExtractComparer(Path inputDir, Path extractsA, Path extractsB,
ExtractReader extractReader, IDBWriter writer,
+ PipesReport pipesReportA, PipesReport pipesReportB)
{
super(writer);
this.inputDir = inputDir;
this.extractsA = extractsA;
this.extractsB = extractsB;
this.extractReader = extractReader;
+ this.pipesReportA = pipesReportA;
+ this.pipesReportB = pipesReportB;
}
public static void USAGE() throws IOException {
@@ -172,6 +184,8 @@ public class ExtractComparer extends ProfilerBase {
long extractFileLengthB = getFileLength(fpsB.getExtractFile());
contData.put(Cols.EXTRACT_FILE_LENGTH_B, extractFileLengthB >
NON_EXISTENT_FILE_LENGTH ? Long.toString(extractFileLengthB) : "");
+ putPipesResult(contData, pipesReportA, fpsA, Cols.PIPES_STATUS_A,
Cols.PIPES_MESSAGE_A);
+ putPipesResult(contData, pipesReportB, fpsB, Cols.PIPES_STATUS_B,
Cols.PIPES_MESSAGE_B);
writer.writeRow(COMPARISON_CONTAINERS, contData);
if (extractExceptionA != null) {
diff --git
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractComparerRunner.java
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractComparerRunner.java
index 9b48f06bf7..6a2c2fdea5 100644
---
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractComparerRunner.java
+++
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractComparerRunner.java
@@ -29,6 +29,7 @@ import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
@@ -62,6 +63,7 @@ import org.apache.tika.eval.app.io.DBWriter;
import org.apache.tika.eval.app.io.ExtractReader;
import org.apache.tika.eval.app.io.ExtractReaderException;
import org.apache.tika.eval.app.io.IDBWriter;
+import org.apache.tika.eval.app.io.PipesReport;
import org.apache.tika.eval.app.reports.ResultsReporter;
import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.mime.MimeTypes;
@@ -92,6 +94,14 @@ public class ExtractComparerRunner {
.addOption(Option.builder("m").longOpt("maxExtractLength").hasArg().desc("maximum
extract length").get())
.addOption(Option.builder("r").longOpt("report").desc("automatically run Report
and tgz after Compare").get())
.addOption(Option.builder("rd").longOpt("reportsDir").hasArg().desc("directory
for reports (default: 'reports')").get())
+
.addOption(Option.builder("pa").longOpt("pipesReportA").hasArg()
+ .desc("optional: jsonl from tika-pipes'
file-system-jsonl-reporter for A; default:
<extractsA>/.run-info/crashes-*.jsonl").get())
+
.addOption(Option.builder("pb").longOpt("pipesReportB").hasArg()
+ .desc("optional: same, for B").get())
+ .addOption(Option.builder("ra").longOpt("runInfoA").hasArg()
+ .desc("optional: run-info json from run-batch.sh for
A; default: <extractsA>/.run-info/run-info-*.json").get())
+ .addOption(Option.builder("rb").longOpt("runInfoB").hasArg()
+ .desc("optional: same, for B").get())
.addOption(Option.builder("z").longOpt("gzip").desc("gzip the
H2 db file (<db>.mv.db.gz) after Compare for transfer; requires -d").get())
;
}
@@ -104,6 +114,15 @@ public class ExtractComparerRunner {
Path extractsBDir = commandLine.hasOption('b') ?
Paths.get(commandLine.getOptionValue('b')) : Paths.get(USAGE_FAIL("Must specify
extractsB dir: -b"));
Path inputDir = commandLine.hasOption('i') ?
Paths.get(commandLine.getOptionValue('i')) : extractsADir;
+ RunInfo.Side sideA = RunInfo.loadSide(optPath(commandLine, "pa"),
optPath(commandLine, "ra"), extractsADir);
+ RunInfo.Side sideB = RunInfo.loadSide(optPath(commandLine, "pb"),
optPath(commandLine, "rb"), extractsBDir);
+ Map<String, String> runInfoA = new LinkedHashMap<>(sideA.batchInfo());
+ runInfoA.putAll(RunInfo.pipesReportInfo(sideA.pipesReport()));
+ runInfoA.putAll(RunInfo.extractsInfo(extractsADir));
+ Map<String, String> runInfoB = new LinkedHashMap<>(sideB.batchInfo());
+ runInfoB.putAll(RunInfo.pipesReportInfo(sideB.pipesReport()));
+ runInfoB.putAll(RunInfo.extractsInfo(extractsBDir));
+
boolean usesTempDb = !commandLine.hasOption('d');
Path tempDbDir = null;
String dbPath;
@@ -124,7 +143,8 @@ public class ExtractComparerRunner {
try {
String jdbcString = getJdbcConnectionString(dbPath);
- execute(inputDir, extractsADir, extractsBDir, jdbcString,
evalConfig);
+ Map<String, String> runInfo = RunInfo.evalInfo(args, evalConfig,
inputDir);
+ execute(inputDir, extractsADir, extractsBDir, jdbcString,
evalConfig, sideA.pipesReport(), sideB.pipesReport(), runInfo, runInfoA,
runInfoB);
if (commandLine.hasOption('r')) {
String reportsDir = commandLine.getOptionValue("rd",
"reports");
@@ -153,6 +173,10 @@ public class ExtractComparerRunner {
}
}
+ private static Path optPath(CommandLine commandLine, String opt) {
+ return commandLine.hasOption(opt) ?
Paths.get(commandLine.getOptionValue(opt)) : null;
+ }
+
private static String getJdbcConnectionString(String dbPath) {
if (dbPath.startsWith("jdbc:")) {
return dbPath;
@@ -163,7 +187,9 @@ public class ExtractComparerRunner {
}
- private static void execute(Path inputDir, Path extractsA, Path extractsB,
String dbPath, EvalConfig evalConfig) throws SQLException, IOException {
+ private static void execute(Path inputDir, Path extractsA, Path extractsB,
String dbPath, EvalConfig evalConfig, PipesReport pipesReportA,
+ PipesReport pipesReportB, Map<String, String>
runInfo, Map<String, String> runInfoA, Map<String, String> runInfoB)
+ throws SQLException, IOException {
//parameterize this? if necesssary
try {
@@ -176,6 +202,12 @@ public class ExtractComparerRunner {
ExtractComparerBuilder builder = new ExtractComparerBuilder();
MimeBuffer mimeBuffer = initTables(jdbcUtil, builder, dbPath,
evalConfig);
builder.populateRefTables(jdbcUtil, mimeBuffer);
+ // before workers start, so an aborted run still says what it was
+ IDBWriter runInfoWriter =
builder.getDBWriter(List.of(RunInfo.RUN_INFO_TABLE, RunInfo.RUN_INFO_TABLE_A,
RunInfo.RUN_INFO_TABLE_B), jdbcUtil, mimeBuffer);
+ RunInfo.write(runInfoWriter, RunInfo.RUN_INFO_TABLE, runInfo);
+ RunInfo.write(runInfoWriter, RunInfo.RUN_INFO_TABLE_A, runInfoA);
+ RunInfo.write(runInfoWriter, RunInfo.RUN_INFO_TABLE_B, runInfoB);
+ runInfoWriter.close();
AtomicInteger enqueued = new AtomicInteger(0);
AtomicInteger processed = new AtomicInteger(0);
@@ -195,7 +227,7 @@ public class ExtractComparerRunner {
for (int i = 0; i < evalConfig.getNumWorkers(); i++) {
ExtractReader extractReader = new
ExtractReader(ExtractReader.ALTER_METADATA_LIST.AS_IS,
evalConfig.getMinExtractLength(), evalConfig.getMaxExtractLength());
ExtractComparer extractComparer = new ExtractComparer(inputDir,
extractsA, extractsB, extractReader,
- builder.getDBWriter(builder.getNonRefTableInfos(),
jdbcUtil, mimeBuffer));
+ builder.getDBWriter(builder.getNonRefTableInfos(),
jdbcUtil, mimeBuffer), pipesReportA, pipesReportB);
executorCompletionService.submit(new ComparerWorker(queue,
extractComparer, processed));
}
@@ -221,6 +253,10 @@ public class ExtractComparerRunner {
} catch (ExecutionException e) {
throw new RuntimeException(e);
} finally {
+ Map<TableInfo, PipesReport> ledgers = new HashMap<>();
+ ledgers.put(RunInfo.RUN_INFO_TABLE_A, pipesReportA);
+ ledgers.put(RunInfo.RUN_INFO_TABLE_B, pipesReportB);
+ RunInfo.finish(runInfoWriter, RunInfo.RUN_INFO_TABLE, ledgers);
mimeBuffer.close();
executorService.shutdownNow();
try {
@@ -379,6 +415,9 @@ public class ExtractComparerRunner {
queue.put(PipesIterator.COMPLETED_SEMAPHORE);
return COMPARER_WORKER_COMPLETED_VALUE;
}
+ if (RunInfo.isRunInfoPath(t.getFetchKey().getFetchKey())) {
+ continue;
+ }
extractComparer.processFileResource(t.getFetchKey());
processed.incrementAndGet();
}
@@ -414,6 +453,9 @@ public class ExtractComparerRunner {
tableInfosAandB.add(ExtractComparer.COMPARISON_CONTAINERS);
tableInfosAandB.add(ExtractComparer.CONTENT_COMPARISONS);
tableInfosAandB.add(ProfilerBase.MIME_TABLE);
+ tableInfosAandB.add(RunInfo.RUN_INFO_TABLE);
+ tableInfosAandB.add(RunInfo.RUN_INFO_TABLE_A);
+ tableInfosAandB.add(RunInfo.RUN_INFO_TABLE_B);
List<TableInfo> refTableInfos = new ArrayList<>();
refTableInfos.add(ExtractComparer.REF_PAIR_NAMES);
diff --git
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractProfileRunner.java
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractProfileRunner.java
index fca4c61473..daa2ddc81d 100644
---
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractProfileRunner.java
+++
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractProfileRunner.java
@@ -27,6 +27,7 @@ import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
@@ -56,6 +57,7 @@ import org.apache.tika.eval.app.io.DBWriter;
import org.apache.tika.eval.app.io.ExtractReader;
import org.apache.tika.eval.app.io.ExtractReaderException;
import org.apache.tika.eval.app.io.IDBWriter;
+import org.apache.tika.eval.app.io.PipesReport;
import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.mime.MimeTypes;
import org.apache.tika.pipes.api.FetchEmitTuple;
@@ -82,6 +84,10 @@ public class ExtractProfileRunner {
.addOption(Option.builder("c").longOpt("config").hasArg().desc("tika-eval json
config file").get())
.addOption(Option.builder("n").longOpt("numWorkers").hasArg().desc("number of
worker threads").get())
.addOption(Option.builder("m").longOpt("maxExtractLength").hasArg().desc("maximum
extract length").get())
+ .addOption(Option.builder("pr").longOpt("pipesReport").hasArg()
+ .desc("optional: jsonl from tika-pipes'
file-system-jsonl-reporter; default:
<extracts>/.run-info/crashes-*.jsonl").get())
+ .addOption(Option.builder("ri").longOpt("runInfo").hasArg()
+ .desc("optional: run-info json written by
run-batch.sh; default: <extracts>/.run-info/run-info-*.json").get())
;
}
@@ -100,7 +106,16 @@ public class ExtractProfileRunner {
if (commandLine.hasOption('m')) {
evalConfig.setMaxExtractLength(Long.parseLong(commandLine.getOptionValue('m')));
}
- execute(inputDir, extractsDir, jdbcString, evalConfig);
+ RunInfo.Side side = RunInfo.loadSide(optPath(commandLine, "pr"),
optPath(commandLine, "ri"), extractsDir);
+ Map<String, String> runInfo = new
LinkedHashMap<>(RunInfo.evalInfo(args, evalConfig, inputDir));
+ runInfo.putAll(side.batchInfo());
+ runInfo.putAll(RunInfo.pipesReportInfo(side.pipesReport()));
+ runInfo.putAll(RunInfo.extractsInfo(extractsDir));
+ execute(inputDir, extractsDir, jdbcString, evalConfig,
side.pipesReport(), runInfo);
+ }
+
+ private static Path optPath(CommandLine commandLine, String opt) {
+ return commandLine.hasOption(opt) ?
Paths.get(commandLine.getOptionValue(opt)) : null;
}
private static String getJdbcConnectionString(String dbPath) {
@@ -113,7 +128,8 @@ public class ExtractProfileRunner {
}
- private static void execute(Path inputDir, Path extractsDir, String
dbPath, EvalConfig evalConfig) throws SQLException, IOException {
+ private static void execute(Path inputDir, Path extractsDir, String
dbPath, EvalConfig evalConfig, PipesReport pipesReport,
+ Map<String, String> runInfo) throws
SQLException, IOException {
//parameterize this? if necesssary
try {
@@ -126,6 +142,10 @@ public class ExtractProfileRunner {
ExtractProfilerBuilder builder = new ExtractProfilerBuilder();
MimeBuffer mimeBuffer = initTables(jdbcUtil, builder, dbPath,
evalConfig);
builder.populateRefTables(jdbcUtil, mimeBuffer);
+ // before workers start, so an aborted run still says what it was
+ IDBWriter runInfoWriter =
builder.getDBWriter(List.of(RunInfo.RUN_INFO_TABLE), jdbcUtil, mimeBuffer);
+ RunInfo.write(runInfoWriter, RunInfo.RUN_INFO_TABLE, runInfo);
+ runInfoWriter.close();
AtomicInteger processed = new AtomicInteger(0);
AtomicInteger activeWorkers = new
AtomicInteger(evalConfig.getNumWorkers());
@@ -143,7 +163,8 @@ public class ExtractProfileRunner {
executorCompletionService.submit(pipesIterator);
for (int i = 0; i < evalConfig.getNumWorkers(); i++) {
ExtractReader extractReader = new
ExtractReader(ExtractReader.ALTER_METADATA_LIST.AS_IS,
evalConfig.getMinExtractLength(), evalConfig.getMaxExtractLength());
- ExtractProfiler extractProfiler = new ExtractProfiler(inputDir,
extractsDir, extractReader, builder.getDBWriter(builder.tableInfos, jdbcUtil,
mimeBuffer));
+ ExtractProfiler extractProfiler = new ExtractProfiler(inputDir,
extractsDir, extractReader, builder.getDBWriter(builder.tableInfos, jdbcUtil,
mimeBuffer),
+ pipesReport);
executorCompletionService.submit(new ProfileWorker(queue,
extractProfiler, processed));
}
@@ -169,6 +190,9 @@ public class ExtractProfileRunner {
} catch (ExecutionException e) {
throw new RuntimeException(e);
} finally {
+ Map<TableInfo, PipesReport> ledgers = new HashMap<>();
+ ledgers.put(RunInfo.RUN_INFO_TABLE, pipesReport);
+ RunInfo.finish(runInfoWriter, RunInfo.RUN_INFO_TABLE, ledgers);
mimeBuffer.close();
executorService.shutdownNow();
}
@@ -238,6 +262,9 @@ public class ExtractProfileRunner {
queue.put(PipesIterator.COMPLETED_SEMAPHORE);
return PROFILE_WORKER_COMPLETED_VALUE;
}
+ if (RunInfo.isRunInfoPath(t.getFetchKey().getFetchKey())) {
+ continue;
+ }
extractProfiler.processFileResource(t.getFetchKey());
processed.incrementAndGet();
}
@@ -259,6 +286,7 @@ public class ExtractProfileRunner {
tableInfos.add(ExtractProfiler.ENCODINGS_TABLE);
tableInfos.add(ExtractProfiler.TAGS_TABLE);
tableInfos.add(ExtractProfiler.EMBEDDED_FILE_PATH_TABLE);
+ tableInfos.add(RunInfo.RUN_INFO_TABLE);
this.tableInfos = Collections.unmodifiableList(tableInfos);
List<TableInfo> refTableInfos = new ArrayList<>();
diff --git
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractProfiler.java
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractProfiler.java
index c2dedff1d1..a604ea9108 100644
---
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractProfiler.java
+++
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractProfiler.java
@@ -31,6 +31,7 @@ import org.apache.tika.eval.app.db.TableInfo;
import org.apache.tika.eval.app.io.ExtractReader;
import org.apache.tika.eval.app.io.ExtractReaderException;
import org.apache.tika.eval.app.io.IDBWriter;
+import org.apache.tika.eval.app.io.PipesReport;
import org.apache.tika.eval.core.util.ContentTags;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
@@ -47,7 +48,8 @@ public class ExtractProfiler extends ProfilerBase {
new ColInfo(Cols.SORT_STACK_TRACE, Types.VARCHAR, 8192),
new ColInfo(Cols.PARSE_EXCEPTION_ID, Types.INTEGER));
public static TableInfo CONTAINER_TABLE =
new TableInfo("containers", new ColInfo(Cols.CONTAINER_ID,
Types.INTEGER, "PRIMARY KEY"), new ColInfo(Cols.FILE_PATH, Types.VARCHAR,
FILE_PATH_MAX_LEN),
- new ColInfo(Cols.LENGTH, Types.BIGINT), new
ColInfo(Cols.EXTRACT_FILE_LENGTH, Types.BIGINT));
+ new ColInfo(Cols.LENGTH, Types.BIGINT), new
ColInfo(Cols.EXTRACT_FILE_LENGTH, Types.BIGINT),
+ new ColInfo(Cols.PIPES_STATUS, Types.VARCHAR,
PIPES_STATUS_MAX_LEN), new ColInfo(Cols.PIPES_MESSAGE, Types.VARCHAR,
PIPES_MESSAGE_MAX_LEN));
public static TableInfo PROFILE_TABLE = new TableInfo("profiles", new
ColInfo(Cols.ID, Types.INTEGER, "PRIMARY KEY"), new ColInfo(Cols.CONTAINER_ID,
Types.INTEGER),
new ColInfo(Cols.FILE_NAME, Types.VARCHAR, 256), new
ColInfo(Cols.MD5, Types.CHAR, 32), new ColInfo(Cols.LENGTH, Types.BIGINT),
new ColInfo(Cols.IS_EMBEDDED, Types.BOOLEAN), new
ColInfo(Cols.EMBEDDED_DEPTH, Types.INTEGER), new
ColInfo(Cols.EMBEDDED_FILE_PATH, Types.VARCHAR, 1024),
@@ -85,13 +87,14 @@ public class ExtractProfiler extends ProfilerBase {
private final Path inputDir;
private final Path extracts;
private final ExtractReader extractReader;
+ private final PipesReport pipesReport;
-
- ExtractProfiler(Path inputDir, Path extracts, ExtractReader extractReader,
IDBWriter dbWriter) {
+ ExtractProfiler(Path inputDir, Path extracts, ExtractReader extractReader,
IDBWriter dbWriter, PipesReport pipesReport) {
super(dbWriter);
this.inputDir = inputDir;
this.extracts = extracts;
this.extractReader = extractReader;
+ this.pipesReport = pipesReport;
}
@@ -128,6 +131,7 @@ public class ExtractProfiler extends ProfilerBase {
if (fps.getExtractFileLength() > 0) {
contOutput.put(Cols.EXTRACT_FILE_LENGTH, (fps.getExtractFile() ==
null) ? "" : Long.toString(fps.getExtractFileLength()));
}
+ putPipesResult(contOutput, pipesReport, fps, Cols.PIPES_STATUS,
Cols.PIPES_MESSAGE);
try {
writer.writeRow(CONTAINER_TABLE, contOutput);
} catch (IOException e) {
diff --git
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ProfilerBase.java
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ProfilerBase.java
index 3989b0732f..b51cd53a48 100644
---
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ProfilerBase.java
+++
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ProfilerBase.java
@@ -45,6 +45,7 @@ import org.apache.tika.eval.app.db.Cols;
import org.apache.tika.eval.app.db.TableInfo;
import org.apache.tika.eval.app.io.ExtractReaderException;
import org.apache.tika.eval.app.io.IDBWriter;
+import org.apache.tika.eval.app.io.PipesReport;
import org.apache.tika.eval.core.langid.LanguageIDWrapper;
import org.apache.tika.eval.core.metadata.TikaEvalMetadataFilter;
import org.apache.tika.eval.core.textstats.BasicTokenCountStatsCalculator;
@@ -86,6 +87,8 @@ public abstract class ProfilerBase {
protected static final AtomicInteger ID = new AtomicInteger();
static final long NON_EXISTENT_FILE_LENGTH = -1l;
final static int FILE_PATH_MAX_LEN = 1024;//max len for varchar for
file_path
+ final static int PIPES_STATUS_MAX_LEN = 64;
+ final static int PIPES_MESSAGE_MAX_LEN = 4096;
//Container exception key from the 1.x branch; read-only lookup against
legacy extract JSON, so a plain String key suffices.
private static final String CONTAINER_EXCEPTION_1X = "X-TIKA" +
":EXCEPTION:runtime";
private static final Logger LOG =
LoggerFactory.getLogger(ProfilerBase.class);
@@ -362,6 +365,18 @@ public abstract class ProfilerBase {
initAnalyzersAndTokenCounter(maxTokens, new LanguageIDWrapper());
}
+ protected static void putPipesResult(Map<Cols, String> row, PipesReport
report, EvalFilePaths fps, Cols statusCol, Cols messageCol) {
+ if (report == null) {
+ return;
+ }
+ PipesReport.Row r = report.get(fps.getRelativeSourceFilePath());
+ if (r == null) {
+ return;
+ }
+ row.put(statusCol, r.status());
+ row.put(messageCol, r.message());
+ }
+
protected void writeExtractException(TableInfo extractExceptionTable,
String containerId, String filePath, ExtractReaderException.TYPE type) throws
IOException {
Map<Cols, String> data = new HashMap<>();
data.put(Cols.CONTAINER_ID, containerId);
diff --git
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/RunInfo.java
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/RunInfo.java
new file mode 100644
index 0000000000..ff14d4996a
--- /dev/null
+++
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/RunInfo.java
@@ -0,0 +1,314 @@
+/*
+ * 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.eval.app;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetAddress;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.security.CodeSource;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.sql.Types;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HexFormat;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.tika.Tika;
+import org.apache.tika.eval.app.db.ColInfo;
+import org.apache.tika.eval.app.db.Cols;
+import org.apache.tika.eval.app.db.TableInfo;
+import org.apache.tika.eval.app.io.IDBWriter;
+import org.apache.tika.eval.app.io.PipesReport;
+
+/**
+ * Provenance for an eval run: what tika-eval this is, what it was pointed at,
and (via
+ * {@code --runInfo}) what batch run produced the extracts. Lands in {@code
run_info}
+ * ({@code run_info_a}/{@code run_info_b} for the two sides of a comparison)
as key/value rows.
+ */
+public class RunInfo {
+
+ private static final Logger LOG = LoggerFactory.getLogger(RunInfo.class);
+
+ public static final TableInfo RUN_INFO_TABLE = new TableInfo("run_info",
+ new ColInfo(Cols.RUN_KEY, Types.VARCHAR, 256), new
ColInfo(Cols.RUN_VALUE, Types.VARCHAR, 4096));
+ public static final TableInfo RUN_INFO_TABLE_A = new
TableInfo("run_info_a", RUN_INFO_TABLE.getColInfos());
+ public static final TableInfo RUN_INFO_TABLE_B = new
TableInfo("run_info_b", RUN_INFO_TABLE.getColInfos());
+
+ public static final String BATCH_PREFIX = "batch.";
+ public static final String RUN_ID_KEY = BATCH_PREFIX + "run.id";
+ public static final String PIPES_REPORT_PATH_KEY = "pipes_report.path";
+ /** Written by run-batch.sh inside the extracts dir; skipped by the crawl
and the fingerprint. */
+ public static final String RUN_INFO_DIR = ".run-info";
+ static final String LEDGER_PREFIX = "crashes-";
+ static final String LEDGER_SUFFIX = ".jsonl";
+
+ private RunInfo() {
+ }
+
+ /** One side's batch inputs: the ledger (may be null) and the flattened
run-info (may be empty). */
+ public record Side(PipesReport pipesReport, Map<String, String> batchInfo)
{
+ public Path pipesReportPath() {
+ return pipesReport == null ? null : pipesReport.getPath();
+ }
+ }
+
+ /**
+ * Resolves and loads one side's ledger and run-info: an explicit path
wins, otherwise
+ * {@code <extracts>/.run-info/} is searched, and the pair is refused if
it is from two runs.
+ */
+ public static Side loadSide(Path explicitPipesReport, Path
explicitRunInfo, Path extracts) throws IOException {
+ Path pr = explicitPipesReport != null ? explicitPipesReport :
discoverPipesReport(extracts);
+ Path ri = explicitRunInfo != null ? explicitRunInfo :
discoverRunInfo(extracts);
+ PipesReport report = pr == null ? null : PipesReport.load(pr);
+ Map<String, String> batch = ri == null ? Map.of() : loadBatch(ri);
+ checkRunId(batch, pr);
+ return new Side(report, batch);
+ }
+
+ /**
+ * The batch script leaves its files in {@code <extracts>/.run-info/}.
Used when the CLI flags are
+ * absent; exactly one run-info there is required, since two means the
extracts were written by two runs.
+ * @return run-info json path or null if the dir has none
+ */
+ public static Path discoverRunInfo(Path extracts) throws IOException {
+ return discover(extracts, "run-info-", ".json");
+ }
+
+ public static Path discoverPipesReport(Path extracts) throws IOException {
+ return discover(extracts, LEDGER_PREFIX, LEDGER_SUFFIX);
+ }
+
+ private static Path discover(Path extracts, String prefix, String suffix)
throws IOException {
+ Path dir = extracts.resolve(RUN_INFO_DIR);
+ if (!Files.isDirectory(dir)) {
+ return null;
+ }
+ List<Path> hits;
+ try (Stream<Path> s = Files.list(dir)) {
+ hits = s.filter(p -> p.getFileName().toString().startsWith(prefix)
&& p.getFileName().toString().endsWith(suffix)).sorted().toList();
+ }
+ if (hits.size() > 1) {
+ throw new IllegalArgumentException(hits.size() + " " + prefix +
"*" + suffix + " files in " + dir +
+ ": the extracts were written by more than one run. Pass
the one you mean explicitly.");
+ }
+ return hits.isEmpty() ? null : hits.get(0);
+ }
+
+ public static boolean isRunInfoPath(String relativePath) {
+ String p = PipesReport.normalize(relativePath);
+ return p.equals(RUN_INFO_DIR) || p.startsWith(RUN_INFO_DIR + "/");
+ }
+
+ /** Flattens the batch script's run-info json into dotted keys under
{@code batch.}. */
+ public static Map<String, String> loadBatch(Path json) throws IOException {
+ JsonNode root = new ObjectMapper().readTree(Files.readString(json,
StandardCharsets.UTF_8));
+ Map<String, String> m = new LinkedHashMap<>();
+ flatten(BATCH_PREFIX, root, m);
+ m.put(BATCH_PREFIX + "run_info.path",
json.toAbsolutePath().toString());
+ return m;
+ }
+
+ private static void flatten(String prefix, JsonNode n, Map<String, String>
m) {
+ if (n.isObject()) {
+ for (Iterator<Map.Entry<String, JsonNode>> it = n.fields();
it.hasNext(); ) {
+ Map.Entry<String, JsonNode> e = it.next();
+ flatten(prefix + e.getKey() + ".", e.getValue(), m);
+ }
+ } else {
+ m.put(prefix.substring(0, prefix.length() - 1), n.isValueNode() ?
n.asText() : n.toString());
+ }
+ }
+
+ /**
+ * A pipes report produced by a different batch run than the run-info
describes is the
+ * failure mode nobody can see after the fact; refuse it. The script names
the ledger
+ * {@code crashes-<run.id>.jsonl}; a run-info with a blank {@code run.id}
cannot vouch for any ledger.
+ */
+ public static void checkRunId(Map<String, String> batch, Path pipesReport)
{
+ if (batch == null || pipesReport == null ||
!batch.containsKey(RUN_ID_KEY)) {
+ return;
+ }
+ String runId = batch.get(RUN_ID_KEY);
+ if (runId == null || runId.isBlank()) {
+ throw new IllegalArgumentException("run-info has a blank run.id;
cannot tie it to " + pipesReport);
+ }
+ String expected = LEDGER_PREFIX + runId + LEDGER_SUFFIX;
+ if (!pipesReport.getFileName().toString().equals(expected)) {
+ throw new IllegalArgumentException("run.id mismatch: run-info says
'" + runId + "' (ledger should be " + expected +
+ ") but the pipes report is " + pipesReport);
+ }
+ }
+
+ public static Map<String, String> pipesReportInfo(PipesReport report) {
+ Map<String, String> m = new LinkedHashMap<>();
+ if (report == null) {
+ return m;
+ }
+ m.put(PIPES_REPORT_PATH_KEY,
report.getPath().toAbsolutePath().toString());
+ m.put("pipes_report.rows", Integer.toString(report.size()));
+ m.put("pipes_report.errors",
Integer.toString(report.getErrors().size()));
+ if (!report.getErrors().isEmpty()) {
+ m.put("pipes_report.last_error",
report.getErrors().get(report.getErrors().size() - 1));
+ }
+ return m;
+ }
+
+ /**
+ * Count and a sha256 over the sorted "relpath size" lines of the extract
set.
+ * @throws IllegalArgumentException if {@code extracts} is not a directory
+ */
+ public static Map<String, String> extractsInfo(Path extracts) throws
IOException {
+ if (!Files.isDirectory(extracts)) {
+ throw new IllegalArgumentException("extracts dir does not exist: "
+ extracts);
+ }
+ Map<String, String> m = new LinkedHashMap<>();
+ m.put("extracts.path", extracts.toAbsolutePath().toString());
+ long start = System.currentTimeMillis();
+ List<String> lines = new ArrayList<>();
+ Files.walkFileTree(extracts, new SimpleFileVisitor<>() {
+ @Override
+ public FileVisitResult preVisitDirectory(Path dir,
BasicFileAttributes attrs) {
+ return isRunInfoPath(extracts.relativize(dir).toString()) ?
FileVisitResult.SKIP_SUBTREE : FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes
attrs) {
+ if (attrs.isRegularFile()) {
+
lines.add(PipesReport.normalize(extracts.relativize(file).toString()) + " " +
attrs.size() + "\n");
+ }
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ Collections.sort(lines);
+ MessageDigest md = sha256();
+ for (String line : lines) {
+ md.update(line.getBytes(StandardCharsets.UTF_8));
+ }
+ m.put("extracts.count", Long.toString(lines.size()));
+ m.put("extracts.fingerprint", HexFormat.of().formatHex(md.digest()));
+ LOG.info("fingerprinted {} extracts under {} in {} ms", lines.size(),
extracts, System.currentTimeMillis() - start);
+ return m;
+ }
+
+ public static Map<String, String> evalInfo(String[] args, EvalConfig
config, Path inputDir) {
+ Map<String, String> m = new LinkedHashMap<>();
+ m.put("eval.tika_version", Tika.getString());
+ m.put("eval.jar_sha256", ownJarSha256());
+ m.put("eval.start", Instant.now().toString());
+ m.put("eval.host", hostName());
+ m.put("eval.user", System.getProperty("user.name"));
+ m.put("eval.java", System.getProperty("java.vendor") + " " +
System.getProperty("java.version"));
+ m.put("eval.args", String.join(" ", args));
+ m.put("eval.config", config.toString());
+ m.put("input.path", inputDir.toAbsolutePath().toString());
+ return m;
+ }
+
+ public static void write(IDBWriter writer, TableInfo table, Map<String,
String> info) throws IOException {
+ for (Map.Entry<String, String> e : info.entrySet()) {
+ Map<Cols, String> row = new HashMap<>();
+ row.put(Cols.RUN_KEY, e.getKey());
+ row.put(Cols.RUN_VALUE, e.getValue());
+ writer.writeRow(table, row);
+ }
+ }
+
+ /**
+ * Records the end time and the join outcome of each ledger, then flushes.
Never throws:
+ * this runs in the runners' {@code finally}, where an exception would
mask the real
+ * failure and skip the executor/connection shutdown after it.
+ */
+ public static void finish(IDBWriter writer, TableInfo evalTable,
Map<TableInfo, PipesReport> reportsByTable) {
+ try {
+ write(writer, evalTable, Map.of("eval.end",
Instant.now().toString()));
+ for (Map.Entry<TableInfo, PipesReport> e :
reportsByTable.entrySet()) {
+ PipesReport r = e.getValue();
+ if (r == null) {
+ continue;
+ }
+ write(writer, e.getKey(), Map.of("pipes_report.joined",
Long.toString(r.getJoined())));
+ if (r.size() > 0 && r.getJoined() == 0) {
+ LOG.warn("no container matched any of the {} rows in {}:
wrong ledger for this extracts dir, or the crawl " +
+ "never saw those files (crawling extracts without
-i cannot see files that crashed)", r.size(), r.getPath());
+ }
+ }
+ writer.close();
+ } catch (IOException | RuntimeException e) {
+ LOG.warn("couldn't write run_info end", e);
+ }
+ }
+
+ private static String ownJarSha256() {
+ try {
+ CodeSource cs =
RunInfo.class.getProtectionDomain().getCodeSource();
+ if (cs == null || cs.getLocation() == null) {
+ return "";
+ }
+ Path p = Path.of(cs.getLocation().toURI());
+ if (!Files.isRegularFile(p)) {
+ return ""; // running from target/classes
+ }
+ MessageDigest md = sha256();
+ try (InputStream is = Files.newInputStream(p)) {
+ byte[] buf = new byte[65536];
+ int n;
+ while ((n = is.read(buf)) > 0) {
+ md.update(buf, 0, n);
+ }
+ }
+ return HexFormat.of().formatHex(md.digest());
+ } catch (IOException | URISyntaxException | RuntimeException e) {
+ LOG.warn("couldn't hash own jar", e);
+ return "";
+ }
+ }
+
+ private static String hostName() {
+ try {
+ return InetAddress.getLocalHost().getHostName();
+ } catch (IOException e) {
+ return "";
+ }
+ }
+
+ private static MessageDigest sha256() {
+ try {
+ return MessageDigest.getInstance("SHA-256");
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/db/Cols.java
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/db/Cols.java
index 9d3ea0ec1c..829e8c752c 100644
---
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/db/Cols.java
+++
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/db/Cols.java
@@ -19,6 +19,10 @@ package org.apache.tika.eval.app.db;
public enum Cols {
//container table
CONTAINER_ID, FILE_PATH, EXTRACT_FILE_LENGTH,
+ PIPES_STATUS, PIPES_MESSAGE, //from the batch run's jsonl reporter, joined
on file_path
+ PIPES_STATUS_A, PIPES_MESSAGE_A, PIPES_STATUS_B, PIPES_MESSAGE_B,
+ //run_info table
+ RUN_KEY, RUN_VALUE,
EXTRACT_FILE_LENGTH_A, //for comparisons
EXTRACT_FILE_LENGTH_B,
diff --git
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/io/PipesReport.java
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/io/PipesReport.java
new file mode 100644
index 0000000000..7efeef7df6
--- /dev/null
+++
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/io/PipesReport.java
@@ -0,0 +1,127 @@
+/*
+ * 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.eval.app.io;
+
+import java.io.BufferedReader;
+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.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.LongAdder;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The per-tuple ledger written by tika-pipes' {@code
file-system-jsonl-reporter} during a
+ * batch run, keyed by {@code FetchEmitTuple.id}. For the filesystem iterator
the id is the
+ * source-relative path, so it joins to {@code containers.file_path}; both
sides are
+ * normalized to '/' here. Read-only after {@link #load}; {@link #get} is
thread-safe.
+ */
+public class PipesReport {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(PipesReport.class);
+
+ public record Row(String status, String message) {
+ }
+
+ private final Path path;
+ private final Map<String, Row> rows;
+ private final List<String> errors;
+ private final LongAdder joined = new LongAdder();
+
+ private PipesReport(Path path, Map<String, Row> rows, List<String> errors)
{
+ this.path = path;
+ this.rows = rows;
+ this.errors = errors;
+ }
+
+ public static PipesReport load(Path path) throws IOException {
+ ObjectMapper mapper = new ObjectMapper();
+ Map<String, Row> rows = new HashMap<>();
+ List<String> errors = new ArrayList<>();
+ int lineNo = 0;
+ try (BufferedReader r = Files.newBufferedReader(path,
StandardCharsets.UTF_8)) {
+ String line;
+ while ((line = r.readLine()) != null) {
+ lineNo++;
+ if (lineNo == 1 && line.startsWith("\uFEFF")) {
+ line = line.substring(1);
+ }
+ if (line.isBlank()) {
+ continue;
+ }
+ JsonNode n;
+ try {
+ n = mapper.readTree(line);
+ } catch (IOException e) {
+ throw new IOException("bad json at " + path + ":" +
lineNo, e);
+ }
+ if (n.hasNonNull("error")) {
+ errors.add(n.get("error").asText());
+ continue;
+ }
+ if (!n.hasNonNull("id") || !n.hasNonNull("status")) {
+ throw new IOException("missing id/status at " + path + ":"
+ lineNo);
+ }
+ String id = normalize(n.get("id").asText());
+ Row row = new Row(n.get("status").asText(),
n.hasNonNull("message") ? n.get("message").asText() : null);
+ // a retried tuple reports more than once; the last word wins
+ rows.put(id, row);
+ }
+ }
+ LOG.info("loaded {} pipes report rows ({} pipeline error lines) from
{}", rows.size(), errors.size(), path);
+ return new PipesReport(path, rows, errors);
+ }
+
+ public static String normalize(String id) {
+ return id.replace('\\', '/');
+ }
+
+ public Row get(Path relativeSourcePath) {
+ Row r = rows.get(normalize(relativeSourcePath.toString()));
+ if (r != null) {
+ joined.increment();
+ }
+ return r;
+ }
+
+ public Path getPath() {
+ return path;
+ }
+
+ public int size() {
+ return rows.size();
+ }
+
+ /** Number of {@link #get} calls that found a row. */
+ public long getJoined() {
+ return joined.sum();
+ }
+
+ /** Final {@code {"error":...}} lines: the pipeline died before the run
finished. */
+ public List<String> getErrors() {
+ return Collections.unmodifiableList(errors);
+ }
+}
diff --git
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/reports/MarkdownSummaryWriter.java
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/reports/MarkdownSummaryWriter.java
index 75bc7d4d4b..eccb1fe71a 100644
---
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/reports/MarkdownSummaryWriter.java
+++
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/reports/MarkdownSummaryWriter.java
@@ -56,8 +56,10 @@ public class MarkdownSummaryWriter {
try (BufferedWriter w = Files.newBufferedWriter(summaryPath)) {
w.write("# Tika Eval Comparison Summary\n\n");
+ writeRunInfo(c, w);
writeOverview(c, w);
writeExtractExceptionSummary(c, w);
+ writePipesCrashSummary(c, w);
writeExceptionSummary(c, w);
writeContentQualitySummary(c, w);
writeOovComparison(c, w);
@@ -76,6 +78,55 @@ public class MarkdownSummaryWriter {
LOG.info("Wrote markdown summary to {}", summaryPath);
}
+ private static void writeRunInfo(Connection c, BufferedWriter w)
+ throws IOException, SQLException {
+ if (!tableExists(c, "RUN_INFO")) {
+ return; // db from a tika-eval before run_info existed
+ }
+ w.write("## Run Info\n\n");
+ w.write("### Eval\n\n");
+ writeQueryAsTable(c, w, "select run_key as RUN_KEY, run_value as
RUN_VALUE from run_info order by run_key");
+ w.write("\n### Batch A\n\n");
+ writeQueryAsTable(c, w, "select run_key as RUN_KEY, run_value as
RUN_VALUE from run_info_a order by run_key");
+ w.write("\n### Batch B\n\n");
+ writeQueryAsTable(c, w, "select run_key as RUN_KEY, run_value as
RUN_VALUE from run_info_b order by run_key");
+ w.write("\n");
+ }
+
+ /** Splits NO_EXTRACT_FILE etc. by what the batch run's jsonl reporter
said about the container. */
+ private static void writePipesCrashSummary(Connection c, BufferedWriter w)
+ throws IOException, SQLException {
+ if (!tableExists(c, "PIPES_CLASS_A")) {
+ return; // built by the reports' before-sql; absent on a
pre-ledger db
+ }
+ w.write("## Extract File Issues by Pipes Status\n\n");
+ w.write("CRASH = OOM/TIMEOUT/UNSPECIFIED_CRASH recorded for this file;
any other recorded status is shown as-is; " +
+ "NO_PIPES_RECORD = a ledger was supplied but has no line for
this file (the batch recorded no failure for it); " +
+ "BATCH_WITHOUT_LEDGER = run-info present but the batch wrote
no ledger (pre-4.1 tika-app); " +
+ "NO_PIPES_REPORT_SUPPLIED = neither given.\n\n");
+ for (String side : new String[]{"a", "b"}) {
+ w.write("### Extract " + side.toUpperCase(java.util.Locale.ROOT) +
"\n\n");
+ writeQueryAsTable(c, w,
+ "select p.classification as CLASSIFICATION,
t.extract_exception_description as TYPE, count(1) as COUNT " +
+ "from extract_exceptions_" + side + " e " +
+ "join pipes_class_" + side + " p on p.container_id =
e.container_id " +
+ "join ref_extract_exception_types t on
t.extract_exception_id = e.extract_exception_id " +
+ "group by p.classification,
t.extract_exception_description order by COUNT desc");
+ w.write("\n");
+ }
+ w.write("### Crash status but extract present (success, status
lost)\n\n");
+ writeQueryAsTable(c, w,
+ "select 'A' as SIDE, c.file_path as FILE, p.pipes_status as
STATUS from containers c " +
+ "join pipes_class_a p on p.container_id = c.container_id where
p.classification = 'CRASH' " +
+ "and not exists (select 1 from extract_exceptions_a e where
e.container_id = c.container_id) " +
+ "union all " +
+ "select 'B', c.file_path, p.pipes_status from containers c " +
+ "join pipes_class_b p on p.container_id = c.container_id where
p.classification = 'CRASH' " +
+ "and not exists (select 1 from extract_exceptions_b e where
e.container_id = c.container_id) " +
+ "order by 1, 2 limit " + TOP_N);
+ w.write("\n");
+ }
+
private static void writeOverview(Connection c, BufferedWriter w)
throws IOException, SQLException {
w.write("## Overview\n\n");
@@ -583,10 +634,14 @@ public class MarkdownSummaryWriter {
}
private static boolean isComparisonDb(Connection c) throws SQLException {
+ return tableExists(c, "CONTENT_COMPARISONS");
+ }
+
+ private static boolean tableExists(Connection c, String table) throws
SQLException {
DatabaseMetaData md = c.getMetaData();
try (ResultSet rs = md.getTables(null, null, "%", null)) {
while (rs.next()) {
- if ("CONTENT_COMPARISONS".equalsIgnoreCase(rs.getString(3))) {
+ if (table.equalsIgnoreCase(rs.getString(3))) {
return true;
}
}
diff --git
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/reports/Report.java
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/reports/Report.java
index 2e8c739a5f..8684820df5 100644
---
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/reports/Report.java
+++
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/reports/Report.java
@@ -80,12 +80,14 @@ public class Report {
try {
dumpReportToWorkbook(st, wb);
+ } catch (SQLException | RuntimeException e) {
+ wb.close(); // no file for a report that failed
+ throw e;
+ }
+ try (OutputStream os = Files.newOutputStream(out)) {
+ wb.write(os);
} finally {
- try (OutputStream os = Files.newOutputStream(out)) {
- wb.write(os);
- } finally {
- wb.close();
- }
+ wb.close();
}
}
}
diff --git
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/reports/ResultsReporter.java
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/reports/ResultsReporter.java
index d9eed292d7..b7afe35e60 100644
---
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/reports/ResultsReporter.java
+++
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/reports/ResultsReporter.java
@@ -312,7 +312,13 @@ public class ResultsReporter {
for (String sql : before) {
long start = System.currentTimeMillis();
LOG.info("processing 'before': {}", sql);
- st.execute(sql);
+ try {
+ st.execute(sql);
+ } catch (SQLException e) {
+ // a db from an older tika-eval lacks newer
columns/tables; keep going
+ LOG.warn("'before' failed, skipping: {}", e.getMessage());
+ continue;
+ }
if (!c.getAutoCommit()) {
c.commit();
LOG.info("committing");
@@ -321,7 +327,11 @@ public class ResultsReporter {
LOG.info("finished in {} ms", elapsed);
}
for (Report r : reports) {
- r.writeReport(c, reportsDirectory);
+ try {
+ r.writeReport(c, reportsDirectory);
+ } catch (SQLException e) {
+ LOG.warn("report '{}' failed, skipping: {}", r.reportName,
e.getMessage());
+ }
}
MarkdownSummaryWriter.write(c, reportsDirectory);
for (String sql : after) {
diff --git a/tika-eval/tika-eval-app/src/main/resources/comparison-reports.xml
b/tika-eval/tika-eval-app/src/main/resources/comparison-reports.xml
index 9955413bc0..d1e126c8a6 100644
--- a/tika-eval/tika-eval-app/src/main/resources/comparison-reports.xml
+++ b/tika-eval/tika-eval-app/src/main/resources/comparison-reports.xml
@@ -23,6 +23,34 @@
<before>
+ <!-- one classification per container; the crash-ledger reports and
summary.md join on it -->
+ <sql>drop table if exists pipes_class_a</sql>
+ <sql>create table pipes_class_a (container_id integer, pipes_status
varchar(64), classification varchar(64))
+ as
+ select c.container_id, c.pipes_status_a,
+ case
+ when c.pipes_status_a in ('OOM', 'TIMEOUT', 'UNSPECIFIED_CRASH') then
'CRASH'
+ when c.pipes_status_a is not null then c.pipes_status_a
+ when exists (select 1 from run_info_a where run_key =
'pipes_report.path') then 'NO_PIPES_RECORD'
+ when exists (select 1 from run_info_a where run_key = 'batch.run.id')
then 'BATCH_WITHOUT_LEDGER'
+ else 'NO_PIPES_REPORT_SUPPLIED'
+ end
+ from containers c
+ </sql>
+ <!-- one classification per container; the crash-ledger reports and
summary.md join on it -->
+ <sql>drop table if exists pipes_class_b</sql>
+ <sql>create table pipes_class_b (container_id integer, pipes_status
varchar(64), classification varchar(64))
+ as
+ select c.container_id, c.pipes_status_b,
+ case
+ when c.pipes_status_b in ('OOM', 'TIMEOUT', 'UNSPECIFIED_CRASH') then
'CRASH'
+ when c.pipes_status_b is not null then c.pipes_status_b
+ when exists (select 1 from run_info_b where run_key =
'pipes_report.path') then 'NO_PIPES_RECORD'
+ when exists (select 1 from run_info_b where run_key = 'batch.run.id')
then 'BATCH_WITHOUT_LEDGER'
+ else 'NO_PIPES_REPORT_SUPPLIED'
+ end
+ from containers c
+ </sql>
<sql>drop index if exists pa_mime_id</sql>
<sql>drop index if exists pb_mime_id</sql>
<sql>create index pa_mime_id on profiles_a (mime_id);</sql>
@@ -1090,6 +1118,93 @@
on e.extract_exception_id=t.extract_exception_id
</sql>
</report>
+
+ <!-- PIPES CRASH LEDGER (pipesReport A option) -->
+ <report reportName="Extract Exceptions by Pipes Status A"
+ reportFilename="exceptions/extract_exceptions_by_pipes_status_a.xlsx"
+ format="xlsx"
+ includeSql="true">
+ <sql>
+ select p.classification, t.extract_exception_description, count(1) cnt
+ from extract_exceptions_a e
+ join pipes_class_a p on p.container_id = e.container_id
+ join ref_extract_exception_types t on t.extract_exception_id =
e.extract_exception_id
+ group by p.classification, t.extract_exception_description
+ order by cnt desc
+ </sql>
+ </report>
+
+ <report reportName="Extract Exceptions with Pipes Status Details A"
+ reportFilename="exceptions/extract_exceptions_pipes_details_a.xlsx"
+ format="xlsx"
+ includeSql="true">
+ <sql>
+ select c.file_path, t.extract_exception_description, p.pipes_status,
p.classification, c.pipes_message_a as pipes_message
+ from extract_exceptions_a e
+ join containers c on c.container_id = e.container_id
+ join pipes_class_a p on p.container_id = e.container_id
+ join ref_extract_exception_types t on t.extract_exception_id =
e.extract_exception_id
+ order by p.classification, c.file_path
+ </sql>
+ </report>
+
+ <report reportName="Crash Status but Extract Present A"
+ reportFilename="exceptions/crash_status_extract_present_a.xlsx"
+ format="xlsx"
+ includeSql="true">
+ <sql>
+ select c.file_path, p.pipes_status, c.pipes_message_a as pipes_message
+ from containers c
+ join pipes_class_a p on p.container_id = c.container_id
+ where p.classification = 'CRASH'
+ and not exists (select 1 from extract_exceptions_a e where
e.container_id = c.container_id)
+ order by c.file_path
+ </sql>
+ </report>
+
+ <!-- PIPES CRASH LEDGER (pipesReport B option) -->
+ <report reportName="Extract Exceptions by Pipes Status B"
+ reportFilename="exceptions/extract_exceptions_by_pipes_status_b.xlsx"
+ format="xlsx"
+ includeSql="true">
+ <sql>
+ select p.classification, t.extract_exception_description, count(1) cnt
+ from extract_exceptions_b e
+ join pipes_class_b p on p.container_id = e.container_id
+ join ref_extract_exception_types t on t.extract_exception_id =
e.extract_exception_id
+ group by p.classification, t.extract_exception_description
+ order by cnt desc
+ </sql>
+ </report>
+
+ <report reportName="Extract Exceptions with Pipes Status Details B"
+ reportFilename="exceptions/extract_exceptions_pipes_details_b.xlsx"
+ format="xlsx"
+ includeSql="true">
+ <sql>
+ select c.file_path, t.extract_exception_description, p.pipes_status,
p.classification, c.pipes_message_b as pipes_message
+ from extract_exceptions_b e
+ join containers c on c.container_id = e.container_id
+ join pipes_class_b p on p.container_id = e.container_id
+ join ref_extract_exception_types t on t.extract_exception_id =
e.extract_exception_id
+ order by p.classification, c.file_path
+ </sql>
+ </report>
+
+ <report reportName="Crash Status but Extract Present B"
+ reportFilename="exceptions/crash_status_extract_present_b.xlsx"
+ format="xlsx"
+ includeSql="true">
+ <sql>
+ select c.file_path, p.pipes_status, c.pipes_message_b as pipes_message
+ from containers c
+ join pipes_class_b p on p.container_id = c.container_id
+ where p.classification = 'CRASH'
+ and not exists (select 1 from extract_exceptions_b e where
e.container_id = c.container_id)
+ order by c.file_path
+ </sql>
+ </report>
+
<report reportName="fixedCatastrophicExtractExceptions"
reportFilename="exceptions/fixed_catastrophic_exceptions_in_b.xlsx"
format="xlsx"
diff --git a/tika-eval/tika-eval-app/src/main/resources/profile-reports.xml
b/tika-eval/tika-eval-app/src/main/resources/profile-reports.xml
index f239e92ba1..3dfec034cc 100644
--- a/tika-eval/tika-eval-app/src/main/resources/profile-reports.xml
+++ b/tika-eval/tika-eval-app/src/main/resources/profile-reports.xml
@@ -22,7 +22,20 @@
<before>
- <!-- <sql>create index on x</sql>-->
+ <!-- one classification per container; the crash-ledger reports and
summary.md join on it -->
+ <sql>drop table if exists pipes_class</sql>
+ <sql>create table pipes_class (container_id integer, pipes_status
varchar(64), classification varchar(64))
+ as
+ select c.container_id, c.pipes_status,
+ case
+ when c.pipes_status in ('OOM', 'TIMEOUT', 'UNSPECIFIED_CRASH') then
'CRASH'
+ when c.pipes_status is not null then c.pipes_status
+ when exists (select 1 from run_info where run_key =
'pipes_report.path') then 'NO_PIPES_RECORD'
+ when exists (select 1 from run_info where run_key = 'batch.run.id')
then 'BATCH_WITHOUT_LEDGER'
+ else 'NO_PIPES_REPORT_SUPPLIED'
+ end
+ from containers c
+ </sql>
</before>
@@ -271,6 +284,50 @@
CONTAINER_LENGTH asc
</sql>
</report>
+
+ <!-- PIPES CRASH LEDGER (pipesReport option) -->
+ <report reportName="Extract Exceptions by Pipes Status"
+ reportFilename="exceptions/extract_exceptions_by_pipes_status.xlsx"
+ format="xlsx"
+ includeSql="true">
+ <sql>
+ select p.classification, t.extract_exception_description, count(1) cnt
+ from extract_exceptions e
+ join pipes_class p on p.container_id = e.container_id
+ join ref_extract_exception_types t on t.extract_exception_id =
e.extract_exception_id
+ group by p.classification, t.extract_exception_description
+ order by cnt desc
+ </sql>
+ </report>
+
+ <report reportName="Extract Exceptions with Pipes Status Details"
+ reportFilename="exceptions/extract_exceptions_pipes_details.xlsx"
+ format="xlsx"
+ includeSql="true">
+ <sql>
+ select c.file_path, t.extract_exception_description, p.pipes_status,
p.classification, c.pipes_message as pipes_message
+ from extract_exceptions e
+ join containers c on c.container_id = e.container_id
+ join pipes_class p on p.container_id = e.container_id
+ join ref_extract_exception_types t on t.extract_exception_id =
e.extract_exception_id
+ order by p.classification, c.file_path
+ </sql>
+ </report>
+
+ <report reportName="Crash Status but Extract Present"
+ reportFilename="exceptions/crash_status_extract_present.xlsx"
+ format="xlsx"
+ includeSql="true">
+ <sql>
+ select c.file_path, p.pipes_status, c.pipes_message as pipes_message
+ from containers c
+ join pipes_class p on p.container_id = c.container_id
+ where p.classification = 'CRASH'
+ and not exists (select 1 from extract_exceptions e where e.container_id
= c.container_id)
+ order by c.file_path
+ </sql>
+ </report>
+
<after>
<!--<sql>drop index on x</sql>
diff --git
a/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/ComparerBatchTest.java
b/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/ComparerBatchTest.java
new file mode 100644
index 0000000000..4fddde403c
--- /dev/null
+++
b/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/ComparerBatchTest.java
@@ -0,0 +1,144 @@
+/*
+ * 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.eval.app;
+
+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.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+
+import org.apache.commons.io.FileUtils;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.eval.app.db.H2Util;
+import org.apache.tika.eval.app.reports.ResultsReporter;
+
+/** End-to-end Compare + Report with the pipes crash ledger and run-info
joined in. */
+public class ComparerBatchTest {
+
+ private static Path DB_DIR;
+ private static Path DB;
+ private static Path REPORTS;
+
+ @BeforeAll
+ public static void setUp() throws Exception {
+ DB_DIR = Files.createTempDirectory("comparer-batch-test");
+ DB = DB_DIR.resolve("mydb");
+ REPORTS = DB_DIR.resolve("reports");
+ Path testDirs =
Paths.get(ComparerBatchTest.class.getResource("/test-dirs").toURI());
+ Path pr = testDirs.resolve("pipes-reports");
+ ExtractComparerRunner.main(new String[]{
+ "-i", testDirs.resolve("raw_input").toString(),
+ "-a", testDirs.resolve("extractsA").toString(),
+ "-b", testDirs.resolve("extractsB").toString(),
+ "-d", DB.toAbsolutePath().toString(),
+ "-pa", pr.resolve("crashes-run-a1.jsonl").toString(),
+ "-pb", pr.resolve("crashes-run-b1.jsonl").toString(),
+ "-ra", pr.resolve("run-info-run-a1.json").toString(),
+ "-rb", pr.resolve("run-info-run-b1.json").toString(),
+ "-r", "-rd", REPORTS.toString()
+ });
+ }
+
+ @AfterAll
+ public static void tearDown() throws Exception {
+ FileUtils.deleteDirectory(DB_DIR.toFile());
+ }
+
+ @Test
+ public void testPipesColumns() throws Exception {
+ assertEquals("OOM", one("select pipes_status_a from containers where
file_path='file9_noextract.txt'"));
+ assertEquals("TIMEOUT", one("select pipes_status_b from containers
where file_path='file9_noextract.txt'"));
+ assertNull(one("select pipes_status_a from containers where
file_path='file2_attachANotB.doc'"));
+ assertEquals("UNSPECIFIED_CRASH", one("select pipes_status_a from
containers where file_path='file12_es.txt'"));
+ assertTrue(one("select pipes_message_a from containers where
file_path='file12_es.txt'").contains("EOFException"));
+ }
+
+ @Test
+ public void testClassification() throws Exception {
+ assertEquals("CRASH", one("select p.classification from pipes_class_a
p join containers c on c.container_id = p.container_id " +
+ "where c.file_path='file9_noextract.txt'"));
+ assertEquals("CRASH", one("select p.classification from pipes_class_b
p join containers c on c.container_id = p.container_id " +
+ "where c.file_path='file9_noextract.txt'"));
+ // B's ledger has no line for file10 and B has no extract for it
+ assertEquals("NO_PIPES_RECORD", one("select p.classification from
pipes_class_b p join containers c on c.container_id = p.container_id " +
+ "where c.file_path='file10_permahang.txt'"));
+ assertEquals("CRASH", one("select p.classification from pipes_class_a
p join containers c on c.container_id = p.container_id " +
+ "where c.file_path='file10_permahang.txt'"));
+ assertEquals("5", one("select run_value from run_info_a where
run_key='pipes_report.joined'"));
+ assertEquals("2", one("select run_value from run_info_b where
run_key='pipes_report.joined'"), "sub\\file9 never joins");
+ }
+
+ /** A db written before the ledger columns/tables existed: Report must
skip what it cannot run, not abort. */
+ @Test
+ public void testReportOnPreLedgerDb() throws Exception {
+ Path old = DB_DIR.resolve("olddb");
+ Files.copy(DB_DIR.resolve("mydb.mv.db"),
DB_DIR.resolve("olddb.mv.db"));
+ try (Connection c = new H2Util(old).getConnection(); Statement st =
c.createStatement()) {
+ for (String t : new String[]{"run_info", "run_info_a",
"run_info_b", "pipes_class_a", "pipes_class_b"}) {
+ st.execute("drop table " + t);
+ }
+ for (String col : new String[]{"pipes_status_a",
"pipes_message_a", "pipes_status_b", "pipes_message_b"}) {
+ st.execute("alter table containers drop column " + col);
+ }
+ }
+ Path reports = DB_DIR.resolve("old-reports");
+ ResultsReporter.main(new String[]{"-db",
old.toAbsolutePath().toString(), "-rd", reports.toString()});
+ assertTrue(Files.isRegularFile(reports.resolve("summary.md")));
+
assertFalse(Files.exists(reports.resolve("exceptions/extract_exceptions_by_pipes_status_a.xlsx")));
+ String summary = Files.readString(reports.resolve("summary.md"),
StandardCharsets.UTF_8);
+ assertFalse(summary.contains("## Run Info"), summary);
+ assertTrue(summary.contains("## Overview"), summary);
+ }
+
+ @Test
+ public void testRunInfoTables() throws Exception {
+ assertEquals("run-a1", one("select run_value from run_info_a where
run_key='batch.run.id'"));
+ assertEquals("run-b1", one("select run_value from run_info_b where
run_key='batch.run.id'"));
+ assertEquals("16", one("select run_value from run_info_a where
run_key='extracts.count'"));
+ assertEquals("1", one("select count(1) from run_info where
run_key='eval.end'"));
+ }
+
+ @Test
+ public void testReportsAndSummary() throws Exception {
+
assertTrue(Files.isRegularFile(REPORTS.resolve("exceptions/extract_exceptions_by_pipes_status_a.xlsx")));
+
assertTrue(Files.isRegularFile(REPORTS.resolve("exceptions/crash_status_extract_present_b.xlsx")));
+ String summary = Files.readString(REPORTS.resolve("summary.md"),
StandardCharsets.UTF_8);
+ assertTrue(summary.contains("## Run Info"), summary);
+ assertTrue(summary.contains("| batch.run.id | run-a1 |"), summary);
+ assertTrue(summary.contains("| CRASH | NO_EXTRACT_FILE |"), summary);
+ // file12_es.txt has an extract in A but a crash status: success with
status lost, not a failure
+ assertTrue(summary.contains("| A | file12_es.txt | UNSPECIFIED_CRASH
|"), summary);
+ }
+
+ private static String one(String sql) throws Exception {
+ try (Connection c = new H2Util(DB).getConnection(); Statement st =
c.createStatement(); ResultSet rs = st.executeQuery(sql)) {
+ assertTrue(rs.next(), "no row for: " + sql);
+ return rs.getString(1);
+ }
+ }
+}
diff --git
a/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/ComparerOneSidedLedgerTest.java
b/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/ComparerOneSidedLedgerTest.java
new file mode 100644
index 0000000000..33507bdf2f
--- /dev/null
+++
b/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/ComparerOneSidedLedgerTest.java
@@ -0,0 +1,103 @@
+/*
+ * 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.eval.app;
+
+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.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+
+import org.apache.commons.io.FileUtils;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.eval.app.db.H2Util;
+
+/** A 3.x baseline has no jsonl ledger; B's is discovered from
extractsB/.run-info without flags. */
+public class ComparerOneSidedLedgerTest {
+
+ @Test
+ public void testOnlyB() throws Exception {
+ Path dir = Files.createTempDirectory("comparer-one-sided");
+ try {
+ Path db = dir.resolve("db");
+ Path reports = dir.resolve("reports");
+ Path testDirs =
Paths.get(getClass().getResource("/test-dirs").toURI());
+ ExtractComparerRunner.main(new String[]{
+ "-i", testDirs.resolve("raw_input").toString(),
+ "-a", testDirs.resolve("extractsA").toString(),
+ "-b", testDirs.resolve("extractsB").toString(),
+ "-d", db.toAbsolutePath().toString(),
+ "-r", "-rd", reports.toString()
+ });
+ try (Connection c = new H2Util(db).getConnection(); Statement st =
c.createStatement()) {
+ try (ResultSet rs = st.executeQuery("select pipes_status_a,
pipes_status_b from containers where file_path='file9_noextract.txt'")) {
+ assertTrue(rs.next());
+ assertNull(rs.getString(1));
+ assertEquals("TIMEOUT", rs.getString(2));
+ }
+ try (ResultSet rs = st.executeQuery("select run_value from
run_info_b where run_key='batch.run.id'")) {
+ assertTrue(rs.next());
+ assertEquals("run-b1", rs.getString(1));
+ }
+ try (ResultSet rs = st.executeQuery("select run_value from
run_info_b where run_key='extracts.count'")) {
+ assertTrue(rs.next());
+ assertEquals("14", rs.getString(1), ".run-info excluded
from the fingerprint walk");
+ }
+ try (ResultSet rs = st.executeQuery("select count(1) from
containers where file_path like '.run-info%'")) {
+ assertTrue(rs.next());
+ assertEquals(0, rs.getInt(1), ".run-info skipped by the
crawl");
+ }
+ try (ResultSet rs = st.executeQuery("select count(1) from
run_info_a")) {
+ assertTrue(rs.next());
+ assertEquals(3, rs.getInt(1), "extracts.* only");
+ }
+ }
+ try (Connection c = new H2Util(db).getConnection(); Statement st =
c.createStatement()) {
+ try (ResultSet rs = st.executeQuery("select p.classification
from pipes_class_a p join containers c on c.container_id = p.container_id " +
+ "where c.file_path='file9_noextract.txt'")) {
+ assertTrue(rs.next());
+ assertEquals("NO_PIPES_REPORT_SUPPLIED", rs.getString(1));
+ }
+ try (ResultSet rs = st.executeQuery("select p.classification,
count(1) from pipes_class_b p join containers c on c.container_id =
p.container_id " +
+ "where c.file_path in ('file9_noextract.txt',
'file10_permahang.txt', 'file1.pdf') group by p.classification order by 1")) {
+ assertTrue(rs.next());
+ assertEquals("CRASH", rs.getString(1));
+ assertEquals(1, rs.getInt(2));
+ assertTrue(rs.next());
+ assertEquals("EMIT_SUCCESS", rs.getString(1));
+ assertTrue(rs.next());
+ assertEquals("NO_PIPES_RECORD", rs.getString(1));
+ }
+ }
+ String summary = Files.readString(reports.resolve("summary.md"),
StandardCharsets.UTF_8);
+ int section = summary.indexOf("## Extract File Issues by Pipes
Status");
+ int a = summary.indexOf("### Extract A", section);
+ int b = summary.indexOf("### Extract B", section);
+ assertTrue(summary.substring(a, b).contains("|
NO_PIPES_REPORT_SUPPLIED | NO_EXTRACT_FILE |"), summary);
+ assertTrue(summary.substring(b).contains("| CRASH |
NO_EXTRACT_FILE |"), summary);
+ } finally {
+ FileUtils.deleteDirectory(dir.toFile());
+ }
+ }
+}
diff --git
a/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/ProfilerBatchTest.java
b/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/ProfilerBatchTest.java
index 9697abaf69..b21051afeb 100644
---
a/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/ProfilerBatchTest.java
+++
b/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/ProfilerBatchTest.java
@@ -17,6 +17,7 @@
package org.apache.tika.eval.app;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
@@ -41,6 +42,7 @@ import org.junit.jupiter.api.Test;
import org.apache.tika.eval.app.db.Cols;
import org.apache.tika.eval.app.db.H2Util;
import org.apache.tika.eval.app.db.TableInfo;
+import org.apache.tika.eval.app.reports.ResultsReporter;
public class ProfilerBatchTest {
@@ -60,10 +62,13 @@ public class ProfilerBatchTest {
.toURI());
DB = DB_DIR.resolve("mydb");
+ Path pipesReports = extractsRoot.resolveSibling("pipes-reports");
String[] args = new String[]{
"-i", inputRoot.toAbsolutePath().toString(),
"-e", extractsRoot.toAbsolutePath().toString(),
- "-d", "jdbc:h2:file:" + DB.toAbsolutePath().toString()
+ "-d", "jdbc:h2:file:" + DB.toAbsolutePath().toString(),
+ "-pr", pipesReports.resolve("crashes-run-a1.jsonl").toString(),
+ "-ri", pipesReports.resolve("run-info-run-a1.json").toString()
};
ExtractProfileRunner.main(args);
@@ -124,6 +129,36 @@ public class ProfilerBatchTest {
assertTrue(fNameList.contains("file9_noextract.txt"),
"file9_noextract.txt");
}
+ @Test
+ public void testPipesStatusJoin() throws Exception {
+ assertEquals("OOM", getSingleResult("select pipes_status from
containers where file_path='file9_noextract.txt'"));
+ assertEquals("EMIT_SUCCESS", getSingleResult("select pipes_status from
containers where file_path='file1.pdf'"));
+ assertEquals(null, getSingleResult("select pipes_status from
containers where file_path='file2_attachANotB.doc'"));
+ assertEquals("CRASH", getSingleResult(
+ "select case when c.pipes_status in ('OOM','TIMEOUT') then
'CRASH' else 'OTHER' end from extract_exceptions e " +
+ "join containers c on c.container_id = e.container_id where
c.file_path='file9_noextract.txt'"));
+ }
+
+ @Test
+ public void testRunInfo() throws Exception {
+ assertEquals("run-a1", getSingleResult("select run_value from run_info
where run_key='batch.run.id'"));
+ assertEquals("16", getSingleResult("select run_value from run_info
where run_key='extracts.count'"));
+ assertEquals("5", getSingleResult("select run_value from run_info
where run_key='pipes_report.rows'"));
+ assertEquals("1", getSingleResult("select run_value from run_info
where run_key='pipes_report.errors'"));
+ assertEquals("64", getSingleResult("select length(run_value) from
run_info where run_key='extracts.fingerprint'"));
+ for (String k : new String[]{"eval.start", "eval.end",
"eval.tika_version", "eval.args", "pipes_report.path"}) {
+ assertNotNull(getSingleResult("select run_value from run_info
where run_key='" + k + "'"), k);
+ }
+ }
+
+ @Test
+ public void testReportsRun() throws Exception {
+ Path reportsDir = DB_DIR.resolve("reports");
+ ResultsReporter.main(new String[]{"-db",
DB.toAbsolutePath().toString(), "-rd", reportsDir.toString()});
+
assertTrue(Files.isRegularFile(reportsDir.resolve("exceptions/extract_exceptions_by_pipes_status.xlsx")));
+
assertTrue(Files.isRegularFile(reportsDir.resolve("exceptions/crash_status_extract_present.xlsx")));
+ }
+
@Test
public void testExtractErrors() throws Exception {
String sql =
diff --git
a/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/ProfilerDiscoveryTest.java
b/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/ProfilerDiscoveryTest.java
new file mode 100644
index 0000000000..b149e21ca2
--- /dev/null
+++
b/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/ProfilerDiscoveryTest.java
@@ -0,0 +1,73 @@
+/*
+ * 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.eval.app;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.eval.app.db.H2Util;
+
+/** Profile crawling the extracts dir itself (no -i), with and without a
.run-info to discover. */
+public class ProfilerDiscoveryTest {
+
+ private static Path testDirs() throws Exception {
+ return
Paths.get(ProfilerDiscoveryTest.class.getResource("/test-dirs").toURI());
+ }
+
+ @Test
+ public void testDiscoversRunInfoAndSkipsItInCrawl(@TempDir Path tmp)
throws Exception {
+ Path db = tmp.resolve("db");
+ ExtractProfileRunner.main(new String[]{"-e",
testDirs().resolve("extractsB").toString(), "-d",
db.toAbsolutePath().toString()});
+ try (Connection c = new H2Util(db).getConnection(); Statement st =
c.createStatement()) {
+ assertEquals("run-b1", one(st, "select run_value from run_info
where run_key='batch.run.id'"));
+ assertEquals("EMIT_SUCCESS", one(st, "select pipes_status from
containers where file_path='file1.pdf'"));
+ assertEquals("0", one(st, "select count(1) from containers where
file_path like '.run-info%'"), ".run-info skipped by the crawl");
+ assertEquals("14", one(st, "select run_value from run_info where
run_key='extracts.count'"));
+ // file9 crashed, so it has no extract and this crawl never sees
it: only file1 joins
+ assertEquals("1", one(st, "select run_value from run_info where
run_key='pipes_report.joined'"));
+ }
+ }
+
+ @Test
+ public void testNoLedgerNoRunInfo(@TempDir Path tmp) throws Exception {
+ Path db = tmp.resolve("db");
+ ExtractProfileRunner.main(new String[]{"-e",
testDirs().resolve("extractsA").toString(), "-d",
db.toAbsolutePath().toString()});
+ try (Connection c = new H2Util(db).getConnection(); Statement st =
c.createStatement()) {
+ assertEquals("0", one(st, "select count(1) from run_info where
run_key like 'batch.%' or run_key like 'pipes_report.%'"));
+ assertEquals("0", one(st, "select count(1) from containers where
pipes_status is not null"));
+ assertTrue(Integer.parseInt(one(st, "select count(1) from
containers")) > 10);
+ assertFalse(one(st, "select run_value from run_info where
run_key='eval.end'").isBlank());
+ }
+ }
+
+ private static String one(Statement st, String sql) throws Exception {
+ try (ResultSet rs = st.executeQuery(sql)) {
+ assertTrue(rs.next(), "no row for: " + sql);
+ return rs.getString(1);
+ }
+ }
+}
diff --git
a/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/RunInfoTest.java
b/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/RunInfoTest.java
new file mode 100644
index 0000000000..4f69bc40e4
--- /dev/null
+++
b/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/RunInfoTest.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.eval.app;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class RunInfoTest {
+
+ private static Path res(String name) throws Exception {
+ return
Paths.get(RunInfoTest.class.getResource("/test-dirs/pipes-reports/" +
name).toURI());
+ }
+
+ private static Path runInfoDir(Path extracts) throws Exception {
+ return Files.createDirectories(extracts.resolve(RunInfo.RUN_INFO_DIR));
+ }
+
+ @Test
+ public void testDiscoverRefusesTwoRuns(@TempDir Path extracts) throws
Exception {
+ Path dir = runInfoDir(extracts);
+ Files.copy(res("run-info-run-a1.json"),
dir.resolve("run-info-run-a1.json"));
+ Files.copy(res("run-info-run-b1.json"),
dir.resolve("run-info-run-b1.json"));
+ assertThrows(IllegalArgumentException.class, () ->
RunInfo.loadSide(null, null, extracts));
+ // an explicit run-info bypasses run-info discovery but the ledger is
still discovered
+ RunInfo.Side side = RunInfo.loadSide(null,
dir.resolve("run-info-run-a1.json"), extracts);
+ assertNull(side.pipesReport());
+ assertEquals("run-a1", side.batchInfo().get(RunInfo.RUN_ID_KEY));
+
+ Files.copy(res("crashes-run-a1.jsonl"),
dir.resolve("crashes-run-a1.jsonl"));
+ Files.copy(res("crashes-run-b1.jsonl"),
dir.resolve("crashes-run-b1.jsonl"));
+ assertThrows(IllegalArgumentException.class, () ->
RunInfo.loadSide(null, dir.resolve("run-info-run-a1.json"), extracts));
+ }
+
+ @Test
+ public void testDiscoverRefusesMismatchedPair(@TempDir Path extracts)
throws Exception {
+ Path dir = runInfoDir(extracts);
+ Files.copy(res("run-info-run-a1.json"),
dir.resolve("run-info-run-a1.json"));
+ Files.copy(res("crashes-run-b1.jsonl"),
dir.resolve("crashes-run-b1.jsonl"));
+ IllegalArgumentException e =
assertThrows(IllegalArgumentException.class, () -> RunInfo.loadSide(null, null,
extracts));
+ assertTrue(e.getMessage().contains("crashes-run-a1.jsonl"),
e.getMessage());
+ }
+
+ @Test
+ public void testDiscoverMatchedPairAndNone(@TempDir Path extracts) throws
Exception {
+ RunInfo.Side none = RunInfo.loadSide(null, null, extracts);
+ assertNull(none.pipesReport());
+ assertTrue(none.batchInfo().isEmpty());
+
+ Path dir = runInfoDir(extracts);
+ Files.copy(res("run-info-run-a1.json"),
dir.resolve("run-info-run-a1.json"));
+ Files.copy(res("crashes-run-a1.jsonl"),
dir.resolve("crashes-run-a1.jsonl"));
+ RunInfo.Side side = RunInfo.loadSide(null, null, extracts);
+ assertEquals(5, side.pipesReport().size());
+ assertEquals("run-a1", side.batchInfo().get(RunInfo.RUN_ID_KEY));
+ }
+
+ @Test
+ public void testCheckRunId() throws Exception {
+ Map<String, String> a1 = Map.of(RunInfo.RUN_ID_KEY, "run-a1");
+ RunInfo.checkRunId(a1, res("crashes-run-a1.jsonl"));
+ RunInfo.checkRunId(a1, null);
+ RunInfo.checkRunId(Map.of(), res("crashes-run-b1.jsonl"));
+ assertThrows(IllegalArgumentException.class, () ->
RunInfo.checkRunId(a1, res("crashes-run-b1.jsonl")));
+ // substring is not enough
+ assertThrows(IllegalArgumentException.class, () ->
RunInfo.checkRunId(Map.of(RunInfo.RUN_ID_KEY, "run-a"),
res("crashes-run-a1.jsonl")));
+ assertThrows(IllegalArgumentException.class, () ->
RunInfo.checkRunId(Map.of(RunInfo.RUN_ID_KEY, ""),
res("crashes-run-a1.jsonl")));
+ }
+
+ @Test
+ public void testExtractsInfo(@TempDir Path extracts) throws Exception {
+ Files.writeString(extracts.resolve("a.json"), "aaa");
+ Files.createDirectories(extracts.resolve("sub"));
+ Files.writeString(extracts.resolve("sub/b.json"), "bb");
+ Files.writeString(runInfoDir(extracts).resolve("run-info-x.json"),
"{}");
+ Map<String, String> m = RunInfo.extractsInfo(extracts);
+ assertEquals("2", m.get("extracts.count"));
+ assertEquals(64, m.get("extracts.fingerprint").length());
+ assertThrows(IllegalArgumentException.class, () ->
RunInfo.extractsInfo(extracts.resolve("nope")));
+ }
+
+}
diff --git
a/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/io/PipesReportTest.java
b/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/io/PipesReportTest.java
new file mode 100644
index 0000000000..962c7f8557
--- /dev/null
+++
b/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/io/PipesReportTest.java
@@ -0,0 +1,92 @@
+/*
+ * 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.eval.app.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+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.nio.file.Paths;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.eval.app.RunInfo;
+
+public class PipesReportTest {
+
+ private static Path res(String name) throws Exception {
+ return
Paths.get(PipesReportTest.class.getResource("/test-dirs/pipes-reports/" +
name).toURI());
+ }
+
+ @Test
+ public void testLoadAndJoin() throws Exception {
+ PipesReport r = PipesReport.load(res("crashes-run-a1.jsonl"));
+ assertEquals(5, r.size());
+ assertEquals(1, r.getErrors().size());
+ assertTrue(r.getErrors().get(0).contains("pipeline died"));
+ assertEquals("OOM", r.get(Paths.get("file9_noextract.txt")).status());
+ assertEquals("EMIT_SUCCESS", r.get(Paths.get("file1.pdf")).status());
+ assertNull(r.get(Paths.get("file1.pdf")).message());
+ assertNull(r.get(Paths.get("nope.pdf")));
+ }
+
+ @Test
+ public void testBackslashIdsJoinOnSlash() throws Exception {
+ PipesReport r = PipesReport.load(res("crashes-run-b1.jsonl"));
+ assertEquals("TIMEOUT", r.get(Paths.get("sub",
"file9_noextract.txt")).status());
+ assertEquals("TIMEOUT",
r.get(Paths.get("sub\\file9_noextract.txt")).status());
+ }
+
+ @Test
+ public void testJoinedCount() throws Exception {
+ PipesReport r = PipesReport.load(res("crashes-run-a1.jsonl"));
+ assertEquals(0, r.getJoined());
+ r.get(Paths.get("file1.pdf"));
+ r.get(Paths.get("nope.pdf"));
+ r.get(Paths.get("file1.pdf"));
+ assertEquals(2, r.getJoined());
+ }
+
+ @Test
+ public void testMalformed(@TempDir Path tmp) throws Exception {
+ Path p = tmp.resolve("x.jsonl");
+ Files.writeString(p,
"\uFEFF{\"id\":\"a\",\"status\":\"OOM\"}\r\n\r\n{\"id\":\"a\",\"status\":\"TIMEOUT\"}\r\n",
StandardCharsets.UTF_8);
+ PipesReport r = PipesReport.load(p);
+ assertEquals(1, r.size());
+ assertEquals("TIMEOUT", r.get(Paths.get("a")).status(), "last row
wins");
+
+ Files.writeString(p, "{\"id\":\"a\"}\n", StandardCharsets.UTF_8);
+ assertTrue(assertThrows(IOException.class, () ->
PipesReport.load(p)).getMessage().contains(":1"));
+ Files.writeString(p, "{\"id\":\"a\",\"status\":\"OOM\"}\nnot json\n",
StandardCharsets.UTF_8);
+ assertTrue(assertThrows(IOException.class, () ->
PipesReport.load(p)).getMessage().contains(":2"));
+ }
+
+ @Test
+ public void testRunInfoFlatten() throws Exception {
+ Map<String, String> batch =
RunInfo.loadBatch(res("run-info-run-a1.json"));
+ assertEquals("run-a1", batch.get("batch.run.id"));
+ assertEquals("4.0.0", batch.get("batch.tika.version"));
+ assertEquals("[\"-Xmx4g\",\"-XX:+UseG1GC\"]",
batch.get("batch.jvm.args"));
+ }
+}
diff --git
a/tika-eval/tika-eval-app/src/test/resources/test-dirs/extractsB/.run-info/crashes-run-b1.jsonl
b/tika-eval/tika-eval-app/src/test/resources/test-dirs/extractsB/.run-info/crashes-run-b1.jsonl
new file mode 100644
index 0000000000..ee8b02a240
--- /dev/null
+++
b/tika-eval/tika-eval-app/src/test/resources/test-dirs/extractsB/.run-info/crashes-run-b1.jsonl
@@ -0,0 +1,3 @@
+{"id":"file1.pdf","status":"EMIT_SUCCESS","category":"SUCCESS","message":null,"elapsedMs":10,"timestamp":"2026-08-27T11:00:00Z"}
+{"id":"sub\\file9_noextract.txt","status":"TIMEOUT","category":"PROCESS_CRASH","message":"timed
out","elapsedMs":60000,"timestamp":"2026-08-27T11:00:01Z"}
+{"id":"file9_noextract.txt","status":"TIMEOUT","category":"PROCESS_CRASH","message":"timed
out after 60000ms","elapsedMs":60000,"timestamp":"2026-08-27T11:01:01Z"}
diff --git
a/tika-eval/tika-eval-app/src/test/resources/test-dirs/extractsB/.run-info/run-info-run-b1.json
b/tika-eval/tika-eval-app/src/test/resources/test-dirs/extractsB/.run-info/run-info-run-b1.json
new file mode 100644
index 0000000000..de404acc1b
--- /dev/null
+++
b/tika-eval/tika-eval-app/src/test/resources/test-dirs/extractsB/.run-info/run-info-run-b1.json
@@ -0,0 +1,4 @@
+{
+ "run": {"id": "run-b1", "note": "candidate", "start":
"2026-08-27T10:59:00Z", "host": "box-b", "user": "tester"},
+ "tika": {"app_path": "/opt/tika/tika-app-4.1.0-SNAPSHOT.jar", "app_sha256":
"def456", "version": "4.1.0-SNAPSHOT"}
+}
diff --git
a/tika-eval/tika-eval-app/src/test/resources/test-dirs/pipes-reports/crashes-run-a1.jsonl
b/tika-eval/tika-eval-app/src/test/resources/test-dirs/pipes-reports/crashes-run-a1.jsonl
new file mode 100644
index 0000000000..8343ff13f5
--- /dev/null
+++
b/tika-eval/tika-eval-app/src/test/resources/test-dirs/pipes-reports/crashes-run-a1.jsonl
@@ -0,0 +1,6 @@
+{"id":"file1.pdf","status":"EMIT_SUCCESS","category":"SUCCESS","message":null,"elapsedMs":12,"timestamp":"2026-08-27T10:00:00Z"}
+{"id":"file9_noextract.txt","status":"OOM","category":"PROCESS_CRASH","message":"java.lang.OutOfMemoryError:
Java heap space","elapsedMs":3000,"timestamp":"2026-08-27T10:00:01Z"}
+{"id":"file10_permahang.txt","status":"TIMEOUT","category":"PROCESS_CRASH","message":"timed
out after 60000ms","elapsedMs":60000,"timestamp":"2026-08-27T10:01:01Z"}
+{"id":"file11_oom.txt","status":"OOM","category":"PROCESS_CRASH","message":"java.lang.OutOfMemoryError:
Java heap space","elapsedMs":900,"timestamp":"2026-08-27T10:01:02Z"}
+{"id":"file12_es.txt","status":"UNSPECIFIED_CRASH","category":"PROCESS_CRASH","message":"java.io.EOFException:
server exited before replying (generation
3)","elapsedMs":5,"timestamp":"2026-08-27T10:01:03Z"}
+{"error":"java.lang.IllegalStateException: pipeline
died","timestamp":"2026-08-27T10:01:04Z"}
diff --git
a/tika-eval/tika-eval-app/src/test/resources/test-dirs/pipes-reports/crashes-run-b1.jsonl
b/tika-eval/tika-eval-app/src/test/resources/test-dirs/pipes-reports/crashes-run-b1.jsonl
new file mode 100644
index 0000000000..ee8b02a240
--- /dev/null
+++
b/tika-eval/tika-eval-app/src/test/resources/test-dirs/pipes-reports/crashes-run-b1.jsonl
@@ -0,0 +1,3 @@
+{"id":"file1.pdf","status":"EMIT_SUCCESS","category":"SUCCESS","message":null,"elapsedMs":10,"timestamp":"2026-08-27T11:00:00Z"}
+{"id":"sub\\file9_noextract.txt","status":"TIMEOUT","category":"PROCESS_CRASH","message":"timed
out","elapsedMs":60000,"timestamp":"2026-08-27T11:00:01Z"}
+{"id":"file9_noextract.txt","status":"TIMEOUT","category":"PROCESS_CRASH","message":"timed
out after 60000ms","elapsedMs":60000,"timestamp":"2026-08-27T11:01:01Z"}
diff --git
a/tika-eval/tika-eval-app/src/test/resources/test-dirs/pipes-reports/run-info-run-a1.json
b/tika-eval/tika-eval-app/src/test/resources/test-dirs/pipes-reports/run-info-run-a1.json
new file mode 100644
index 0000000000..c2d35bbbae
--- /dev/null
+++
b/tika-eval/tika-eval-app/src/test/resources/test-dirs/pipes-reports/run-info-run-a1.json
@@ -0,0 +1,6 @@
+{
+ "run": {"id": "run-a1", "note": "baseline", "start": "2026-08-27T09:59:00Z",
"host": "box-a", "user": "tester"},
+ "tika": {"app_path": "/opt/tika/tika-app-4.0.0.jar", "app_sha256": "abc123",
"version": "4.0.0", "git_commit": "deadbeef"},
+ "config": {"path": "/opt/tika/tika-config.json", "sha256": "cfg123"},
+ "jvm": {"version": "17.0.9", "args": ["-Xmx4g", "-XX:+UseG1GC"]}
+}
diff --git
a/tika-eval/tika-eval-app/src/test/resources/test-dirs/pipes-reports/run-info-run-b1.json
b/tika-eval/tika-eval-app/src/test/resources/test-dirs/pipes-reports/run-info-run-b1.json
new file mode 100644
index 0000000000..de404acc1b
--- /dev/null
+++
b/tika-eval/tika-eval-app/src/test/resources/test-dirs/pipes-reports/run-info-run-b1.json
@@ -0,0 +1,4 @@
+{
+ "run": {"id": "run-b1", "note": "candidate", "start":
"2026-08-27T10:59:00Z", "host": "box-b", "user": "tester"},
+ "tika": {"app_path": "/opt/tika/tika-app-4.1.0-SNAPSHOT.jar", "app_sha256":
"def456", "version": "4.1.0-SNAPSHOT"}
+}