From 41a579122946607ea11fb82fd1850a98691c8439 Mon Sep 17 00:00:00 2001 From: duncdrum Date: Sun, 26 Jul 2026 20:29:08 +0200 Subject: [PATCH 1/2] [ci] Publish JMH benchmark results to a gh-pages dashboard exist-core-jmh and exist-indexes-jmh have no continuous tracking: numbers only exist as one-off local runs pasted into PR descriptions. Port the gh-pages benchmark-dashboard approach from BetaMasaheft/betmas-e2e (benchmark-action/github-action-benchmark) so JMH results accumulate as a time series instead. Adds .github/workflows/ci-benchmarks.yml: builds and runs both JMH modules weekly (and on manual dispatch) with reduced iteration counts suitable for a shared runner (-wi 2 -i 5 -f 1 - a trend signal, not a publishable number), then pushes each module's JSON output to gh-pages under dev/bench/core and dev/bench/indexes via the jmh tool adapter. Report-only: alerts on >150% regressions via commit comment, never fails the run. Publish steps are guarded to github.repository == 'eXist-db/exist' so a fork running this workflow doesn't attempt to push to someone else's gh-pages. GitHub Pages itself still needs to be enabled in this repo's Settings once the workflow has run at least once and created the gh-pages branch. Adds a "Continuous tracking" section to exist-indexes-jmh/README.md and drops the roadmap item ("CI nightly job posting numbers to a tracking issue") this supersedes. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci-benchmarks.yml | 100 ++++++++++++++++++++++++++++ exist-indexes-jmh/README.md | 5 +- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci-benchmarks.yml diff --git a/.github/workflows/ci-benchmarks.yml b/.github/workflows/ci-benchmarks.yml new file mode 100644 index 00000000000..71e8e49d631 --- /dev/null +++ b/.github/workflows/ci-benchmarks.yml @@ -0,0 +1,100 @@ +name: JMH Benchmarks +on: + workflow_dispatch: + schedule: + # Weekly, Sunday 04:00 UTC - distinct from ci-container.yml (daily 06:00) + # and prethink.yml (Monday 06:00) so runners aren't all queued together. + - cron: '0 4 * * 0' + +permissions: + contents: read # default; the benchmarks job declares write explicitly + +# Only one publish to gh-pages at a time; a manual dispatch overlapping the +# scheduled run would otherwise race pushing the benchmark-data commit. +concurrency: + group: jmh-benchmarks-gh-pages + cancel-in-progress: false + +env: + DEV_JDK: '21' + # Kept small enough that both benchmark suites fit in the job timeout on a + # shared GitHub-hosted runner; not intended to produce publishable numbers, + # only a consistent trend line. See exist-core-jmh/README.md and + # exist-indexes-jmh/README.md for slower, more precise local invocations. + JMH_ARGS: '-wi 2 -i 5 -f 1' + +jobs: + benchmarks: + name: Run JMH benchmarks + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: write # github-action-benchmark pushes the data series to gh-pages + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ env.DEV_JDK }} + + - uses: ./.github/actions/maven-cache + + - name: Build exist-core-jmh + run: > + mvn -B install -pl exist-core-jmh -am -DskipTests + -Ddependency-check.skip=true -Ddocker=false + + - name: Run exist-core-jmh benchmarks + run: > + java -jar exist-core-jmh/target/exist-core-jmh-*-benchmarks.jar + -rf json -rff exist-core-jmh/target/jmh-result.json + ${{ env.JMH_ARGS }} + + - name: Track exist-core-jmh results on gh-pages + if: github.repository == 'eXist-db/exist' + uses: benchmark-action/github-action-benchmark@v1 + with: + name: 'exist-core-jmh' + tool: 'jmh' + output-file-path: exist-core-jmh/target/jmh-result.json + benchmark-data-dir-path: dev/bench/core + github-token: ${{ secrets.GITHUB_TOKEN }} + auto-push: true + # Report-only: flag regressions, never fail the run. + alert-threshold: '150%' + comment-on-alert: true + fail-on-alert: false + summary-always: true + + - name: Build exist-indexes-jmh + run: > + mvn -B install -pl exist-indexes-jmh -am -DskipTests + -Ddependency-check.skip=true -Ddocker=false + + - name: Run exist-indexes-jmh benchmarks + run: > + mvn -B exec:exec -pl exist-indexes-jmh + -Dbenchmark.args="-rf json -rff target/jmh-result.json ${{ env.JMH_ARGS }}" + + - name: Track exist-indexes-jmh results on gh-pages + if: github.repository == 'eXist-db/exist' + uses: benchmark-action/github-action-benchmark@v1 + with: + name: 'exist-indexes-jmh' + tool: 'jmh' + output-file-path: exist-indexes-jmh/target/jmh-result.json + benchmark-data-dir-path: dev/bench/indexes + github-token: ${{ secrets.GITHUB_TOKEN }} + auto-push: true + alert-threshold: '150%' + comment-on-alert: true + fail-on-alert: false + summary-always: true + + - name: Save Maven cache + if: github.event_name == 'schedule' + uses: actions/cache/save@v6 + with: + path: ~/.m2 + key: maven-${{ github.ref_name }}-${{ hashFiles('**/pom.xml') }} diff --git a/exist-indexes-jmh/README.md b/exist-indexes-jmh/README.md index 2861451a831..718a6224c80 100644 --- a/exist-indexes-jmh/README.md +++ b/exist-indexes-jmh/README.md @@ -158,11 +158,14 @@ exist-indexes-jmh/ `conf.xml` is generated at build time by `xml-maven-plugin` from `extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml` plus the XSLT in `src/main/xslt/conf-jmh.xslt`. Mirroring the `exist-docker` pattern keeps the conf in sync with upstream rather than vendored as a literal copy. +## Continuous tracking + +[`.github/workflows/ci-benchmarks.yml`](../.github/workflows/ci-benchmarks.yml) runs this suite weekly (and on manual dispatch) with reduced iteration counts (`-wi 2 -i 5 -f 1` — a trend signal, not a publishable number) and publishes JSON results via [`benchmark-action/github-action-benchmark`](https://github.com/benchmark-action/github-action-benchmark) to the `gh-pages` branch, under `dev/bench/indexes`. Once GitHub Pages is enabled for this repository, the dashboard is served at `https://exist-db.github.io/exist/dev/bench/indexes/`. + ## Roadmap Planned follow-ups (not in this PR): - Larger corpus via on-demand XMark generation (`xmlgen -f 0.1 / 1 / 10`) -- CI nightly job posting numbers to a tracking issue - `range:eq` / `range:field-eq` family extensions (`range:gt`, `range:starts-with`, etc.) if regressions turn out to be operator-specific - Diagnostics that explain *why* range, range-field, and lucene are immune to the bug ngram exhibits — the surface explanation ("their `getDependencies()` returns the right thing") is true but not actionable; the underlying difference would inform the GH-2204 fix design From 6979d59394b4f98ab0e138a2fc043186009a80e6 Mon Sep 17 00:00:00 2001 From: duncdrum Date: Sun, 26 Jul 2026 20:29:22 +0200 Subject: [PATCH 2/2] [test] Add JMH benchmark for util:expand match-highlighting cost Refs https://github.com/eXist-db/exist/issues/6387 PR #6318 (util:expand Lucene term-rewrite cache, closes #5738) originally included two perf-ratio assertions in surefire (UtilExpandHighlightingPerformanceTest); a later commit on that PR removed them per review - hard perf thresholds are flaky on shared CI runners, and the index-extension reactor had no benchmark module yet. #6387 tracked migrating them to JMH. exist-core-jmh already depends on exist-index-lucene and hosts other Lucene benchmarks (LucenePhraseQueryBenchmark, ReindexDeleteStrategyBenchmark), so this adds UtilExpandHighlightingBenchmark there rather than standing up the separate extensions/indexes/ reactor #6387 originally proposed - that gap it assumed no longer exists. Mirrors ArrowOperatorBenchmark's arrow*/direct* pairing: each shape has a *HighlightingOff / *HighlightingOn pair over an identical corpus and compiled query, so the dashboard shows both series and their trend rather than asserting a single ratio threshold. - expandSingleHit{Off,On}: single hit via subsequence($hits, 1, 1) - expandBatchWildcard{Off,On}: util:expand($hits) over ~half of a 5,000-entry corpus matched by a lemma:a* wildcard - the path the per-Query term-rewrite cache targets Each benchmark still asserts the expected hit count (a "fast but wrong" guard, not a performance threshold). Smoke-tested locally (-wi 1 -i 1 -f 1 -w 3s -r 3s): expandBatchWildcardHighlightingOff ~41ms/op vs HighlightingOn ~228ms/op, consistent with PR #6318's measured ~4-5x. Co-Authored-By: Claude Sonnet 5 --- exist-core-jmh/README.md | 48 +++ .../UtilExpandHighlightingBenchmark.java | 290 ++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 exist-core-jmh/README.md create mode 100644 exist-core-jmh/src/main/java/org/exist/indexing/lucene/UtilExpandHighlightingBenchmark.java diff --git a/exist-core-jmh/README.md b/exist-core-jmh/README.md new file mode 100644 index 00000000000..aa84568db07 --- /dev/null +++ b/exist-core-jmh/README.md @@ -0,0 +1,48 @@ +# exist-core-jmh + +JMH micro-benchmarks for `exist-core` (and the Lucene index extension, which several benchmarks here also exercise). Each class exists to make a specific optimization or regression claim defensible with numbers instead of prose — see the class-level Javadoc for the issue/PR each one is a companion to. + +## What's benchmarked + +| Class | Package | Measures | +|---|---|---| +| `AxisBenchmark` | `org.exist.dom.persistent` | Sibling/non-sibling axis evaluation over three corpus shapes ([#2697](https://github.com/eXist-db/exist/issues/2697)) | +| `PrecedingAxisBenchmark` | `org.exist.xquery` | `preceding::*` position-dependence on a flat 50,000-element document ([#2129](https://github.com/eXist-db/exist/issues/2129)) | +| `ArrowOperatorBenchmark` | `org.exist.xquery` | Overhead of `=>` vs. the equivalent direct call | +| `TypeSubTypeOfBenchmark` | `org.exist.xquery.value` | `Type#subTypeOf(int, int)`, the hot path behind every atomic comparison ([#6322](https://github.com/eXist-db/exist/issues/6322)) | +| `StringJoinBenchmark` | `org.exist.xquery.utils` | Java `String` join strategies | +| `URIUtilsBenchmark` | `org.exist.xquery.utils` | URI utility helpers | +| `LockTableBenchmark` | `org.exist.storage.lock` | Lock table contention | +| `ReindexBenchmark` | `org.exist.storage` | End-to-end `xmldb:reindex()` fast path ([#572](https://github.com/eXist-db/exist/issues/572)) | +| `LucenePhraseQueryBenchmark` | `org.exist.indexing.lucene` | Query-time Lucene phrase lookup over many small documents ([#2812](https://github.com/eXist-db/exist/issues/2812)) | +| `ReindexDeleteStrategyBenchmark` | `org.exist.indexing.lucene` | Reindex-time Lucene delete strategies for mixed document shapes | +| `UtilExpandHighlightingBenchmark` | `org.exist.indexing.lucene` | `util:expand` match-highlighting cost vs. highlighting off, single-hit and batch ([#5738](https://github.com/eXist-db/exist/issues/5738), [#6387](https://github.com/eXist-db/exist/issues/6387)) | + +## Running + +Build the module first — this also installs a fresh `exist-core` into the local Maven repo so the benchmark picks up your branch's code: + +```bash +JAVA_HOME=/path/to/java-21 \ + mvn install -pl exist-core-jmh -am -DskipTests \ + -Ddependency-check.skip=true -Ddocker=false +``` + +The `package` phase shades an uber-jar with `org.openjdk.jmh.Main` as its entry point (`target/exist-core-jmh--benchmarks.jar`). Run it directly: + +```bash +java -jar exist-core-jmh/target/exist-core-jmh-*-benchmarks.jar AxisBenchmark -wi 3 -i 5 -f 1 +``` + +Useful variants: + +- `... -rf json -rff target/jmh-result.json` — machine-readable output (this is what CI feeds to the gh-pages dashboard) +- `... -prof gc` — GC profile +- `... TypeSubTypeOfBenchmark.identical` — filter by regex +- No class filter runs every `@Benchmark` in the jar + +`ArrowOperatorBenchmark` needs the *unshaded* classes plus runtime classpath instead of the shaded jar — the shade transformer trips a log4j2 caller-class assertion when booting a `BrokerPool`. See that class's Javadoc for the exact invocation. + +## Continuous tracking + +[`.github/workflows/ci-benchmarks.yml`](../.github/workflows/ci-benchmarks.yml) runs the full suite weekly (and on manual dispatch) with reduced iteration counts (`-wi 2 -i 5 -f 1` — a trend signal, not a publishable number) and publishes JSON results via [`benchmark-action/github-action-benchmark`](https://github.com/benchmark-action/github-action-benchmark) to the `gh-pages` branch, under `dev/bench/core`. Once GitHub Pages is enabled for this repository, the dashboard is served at `https://exist-db.github.io/exist/dev/bench/core/`. diff --git a/exist-core-jmh/src/main/java/org/exist/indexing/lucene/UtilExpandHighlightingBenchmark.java b/exist-core-jmh/src/main/java/org/exist/indexing/lucene/UtilExpandHighlightingBenchmark.java new file mode 100644 index 00000000000..f99b2415434 --- /dev/null +++ b/exist-core-jmh/src/main/java/org/exist/indexing/lucene/UtilExpandHighlightingBenchmark.java @@ -0,0 +1,290 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.indexing.lucene; + +import org.exist.EXistException; +import org.exist.collections.Collection; +import org.exist.collections.CollectionConfigurationException; +import org.exist.collections.CollectionConfigurationManager; +import org.exist.collections.triggers.TriggerException; +import org.exist.security.PermissionDeniedException; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.storage.txn.TransactionManager; +import org.exist.storage.txn.Txn; +import org.exist.test.ExistEmbeddedServer; +import org.exist.util.DatabaseConfigurationException; +import org.exist.util.LockException; +import org.exist.util.MimeType; +import org.exist.util.StringInputSource; +import org.exist.xmldb.XmldbURI; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.XQuery; +import org.exist.xquery.CompiledXQuery; +import org.exist.xquery.value.Sequence; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; +import org.xml.sax.SAXException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.TimeUnit; + +/** + * JMH benchmark for {@code util:expand} match-highlighting cost against Lucene full-text + * hits, migrated from the perf-ratio assertions in the {@code UtilExpandHighlightingPerformanceTest} + * that PR #6318 initially added and then removed (see #6387). + * + *

PR #6318 fixed #5738: {@code + * util:expand} with match-highlighting on was orders of magnitude slower than with highlighting + * off, because {@link LuceneMatchListener#reset(org.exist.storage.DBBroker, org.exist.dom.persistent.NodeProxy)} + * re-rewrote each query's Lucene terms on every node. The fix caches rewritten terms per Query + * identity (bounded LRU, see {@code LuceneMatchListener}) and short-circuits {@code scanMatches} + * when the configured-field exclusion (PR #3467) produces an empty term map.

+ * + *

Each shape below has a paired {@code *HighlightingOff} / {@code *HighlightingOn} benchmark + * over an identical corpus and query, mirroring {@code ArrowOperatorBenchmark}'s + * {@code arrow*}/{@code direct*} pairing: the two series plotted together on the JMH dashboard + * show both the absolute cost and its trend, without a hard-coded ratio threshold (which would be + * flaky on shared CI runners - the reason these two tests were pulled out of surefire in the + * first place).

+ * + *
    + *
  • {@code expandSingleHit*} - a single hit expanded via {@code subsequence($hits, 1, 1)}. + * Pre-#6318 this was already fast (the per-Query cache mostly helps batch callers), so this + * pair mainly guards against a future regression re-introducing per-hit term rewriting.
  • + *
  • {@code expandBatchWildcard*} - {@code util:expand($hits)} over ~half of a 5,000-entry + * corpus matched by a {@code lemma:a*} wildcard. This is the path the term-rewrite cache + * targets: every hit shares one query identity, so the cache turns N term-rewrites into 1.
  • + *
+ * + *

Build & run (from project root)

+ *
{@code
+ * mvn install -pl exist-core-jmh -am -DskipTests \
+ *     -Ddependency-check.skip=true -Ddocker=false
+ * java -jar exist-core-jmh/target/exist-core-jmh-7.0.0-SNAPSHOT-benchmarks.jar \
+ *     UtilExpandHighlightingBenchmark -f 1 -wi 2 -i 5
+ * }
+ * + * @see #5738 + * @see #6318 + * @see #6387 + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS) +@Fork(1) +@State(Scope.Benchmark) +public class UtilExpandHighlightingBenchmark { + + private static final XmldbURI TEST_COLLECTION = XmldbURI.create("/db/bench-util-expand-highlight"); + + private static final int ENTRY_COUNT = 5000; + private static final int PARAGRAPHS_PER_ENTRY = 20; + + private static final String LUCENE_CONFIG = """ + + + + + + + + + + + """; + + private static final String COLL = "collection('" + TEST_COLLECTION + "')"; + private static final String HIGHLIGHT_OFF_OPTIONS = "'highlight-matches=none expand-xincludes=no'"; + + private ExistEmbeddedServer server; + private BrokerPool pool; + + private CompiledXQuery singleHitHighlightingOffQuery; + private CompiledXQuery singleHitHighlightingOnQuery; + private CompiledXQuery batchWildcardHighlightingOffQuery; + private CompiledXQuery batchWildcardHighlightingOnQuery; + + private int expectedWildcardHitCount; + + @Setup(Level.Trial) + public void setUp() throws EXistException, DatabaseConfigurationException, IOException, + PermissionDeniedException, CollectionConfigurationException, LockException, + SAXException, TriggerException, XPathException { + ensureExistHome(); + final Properties configProperties = new Properties(); + // BrokerPool expects a Long for this property (see BrokerPool.PROPERTY_SHUTDOWN_DELAY). + configProperties.put("wait-before-shutdown", 0L); + server = new ExistEmbeddedServer(configProperties, true, true); + server.startDb(); + pool = server.getBrokerPool(); + + storeCorpus(); + + // Half the corpus (even i) gets an 'a'-prefixed headword; matches the lemma:a* wildcard. + expectedWildcardHitCount = (ENTRY_COUNT + 1) / 2; + + try (final DBBroker broker = pool.get(Optional.of(pool.getSecurityManager().getSystemSubject()))) { + final XQuery xquery = pool.getXQueryService(); + + singleHitHighlightingOffQuery = compile(xquery, broker, + "util:expand(subsequence(" + COLL + "//entry[ft:query(., 'aword42')], 1, 1), " + + HIGHLIGHT_OFF_OPTIONS + ")"); + singleHitHighlightingOnQuery = compile(xquery, broker, + "util:expand(subsequence(" + COLL + "//entry[ft:query(., 'aword42')], 1, 1))"); + batchWildcardHighlightingOffQuery = compile(xquery, broker, + "util:expand(" + COLL + "//entry[ft:query(., 'lemma:a*')], " + HIGHLIGHT_OFF_OPTIONS + ")"); + batchWildcardHighlightingOnQuery = compile(xquery, broker, + "util:expand(" + COLL + "//entry[ft:query(., 'lemma:a*')])"); + } + } + + private static CompiledXQuery compile(final XQuery xquery, final DBBroker broker, final String query) + throws XPathException, PermissionDeniedException { + final XQueryContext context = new XQueryContext(broker.getBrokerPool()); + return xquery.compile(context, query); + } + + private static void ensureExistHome() { + if (System.getProperty("exist.home") != null) { + return; + } + // Prefer the Lucene extension's own test conf (it registers the Lucene index/module and + // the 'ft' function prefix); fall back to exist-core's if run outside a full checkout. + final Path luceneHome = Path.of("extensions", "indexes", "lucene", "src", "test", "resources-filtered") + .toAbsolutePath() + .normalize(); + final Path existHome = Files.exists(luceneHome.resolve("conf.xml")) + ? luceneHome + : Path.of("exist-core", "src", "test", "resources-filtered").toAbsolutePath().normalize(); + System.setProperty("exist.home", existHome.toString()); + } + + @TearDown(Level.Trial) + public void tearDown() { + if (server != null) { + server.stopDb(); + } + } + + @Benchmark + public int expandSingleHitHighlightingOff() throws EXistException, PermissionDeniedException, XPathException, IOException { + return execute(singleHitHighlightingOffQuery, 1); + } + + @Benchmark + public int expandSingleHitHighlightingOn() throws EXistException, PermissionDeniedException, XPathException, IOException { + return execute(singleHitHighlightingOnQuery, 1); + } + + @Benchmark + public int expandBatchWildcardHighlightingOff() throws EXistException, PermissionDeniedException, XPathException, IOException { + return execute(batchWildcardHighlightingOffQuery, expectedWildcardHitCount); + } + + @Benchmark + public int expandBatchWildcardHighlightingOn() throws EXistException, PermissionDeniedException, XPathException, IOException { + return execute(batchWildcardHighlightingOnQuery, expectedWildcardHitCount); + } + + /** + * Runs the query and returns the resulting node count, throwing if it doesn't match the + * expected hit count - a "fast but wrong" guard, not a performance threshold (see class + * Javadoc: the ratio itself is read off the JMH/dashboard series, not asserted here). + */ + private int execute(final CompiledXQuery compiledQuery, final int expectedCount) + throws EXistException, PermissionDeniedException, XPathException, IOException { + try (final DBBroker broker = pool.get(Optional.of(pool.getSecurityManager().getSystemSubject()))) { + final XQuery xquery = pool.getXQueryService(); + final Sequence result = xquery.execute(broker, compiledQuery, null); + final int count = result.getItemCount(); + if (count != expectedCount) { + throw new IllegalStateException("Expected " + expectedCount + " top-level results, got " + count); + } + return count; + } + } + + private void storeCorpus() throws EXistException, PermissionDeniedException, IOException, + CollectionConfigurationException, LockException, SAXException, TriggerException { + final TransactionManager transact = pool.getTransactionManager(); + try (final DBBroker broker = pool.get(Optional.of(pool.getSecurityManager().getSystemSubject())); + final Txn tx = transact.beginTransaction()) { + + final Collection coll = broker.getOrCreateCollection(tx, TEST_COLLECTION); + broker.saveCollection(tx, coll); + + final CollectionConfigurationManager mgr = pool.getConfigurationManager(); + mgr.addConfiguration(tx, broker, coll, LUCENE_CONFIG); + + broker.storeDocument(tx, XmldbURI.create("dict.xml"), new StringInputSource(generateCorpus()), + MimeType.XML_TYPE, coll); + + transact.commit(tx); + } + } + + /** + * Dict/entry corpus: {@value #ENTRY_COUNT} entries, half with an 'a'-prefixed headword (the + * {@code lemma:a*} wildcard target), each padded with {@value #PARAGRAPHS_PER_ENTRY} + * paragraphs so per-entry tokenization cost is measurable - mirrors the corpus shape in the + * original (deleted) {@code UtilExpandHighlightingPerformanceTest}, minus the TEI namespace + * (dropped in the xqsuite migration as boilerplate without correctness value; irrelevant to + * the perf shape measured here). + */ + private static String generateCorpus() { + final StringBuilder doc = new StringBuilder(); + doc.append("\n"); + for (int i = 0; i < ENTRY_COUNT; i++) { + final String letter = (i % 2 == 0) ? "a" : "b"; + final String word = letter + "word" + i; + doc.append(" ") + .append("
").append(word).append("
") + .append("Definition for ").append(word).append(". "); + for (int j = 0; j < PARAGRAPHS_PER_ENTRY; j++) { + doc.append("This is paragraph ").append(j).append(" of the explanation for ") + .append(word).append(", with additional descriptive sentences ") + .append("that emulate real lexicographic content. The headword ") + .append(word).append(" appears multiple times in the body. "); + } + doc.append("
\n"); + } + doc.append("
\n"); + return doc.toString(); + } +}