Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/workflows/search-backend-mwe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Runs the search-backend MWE suite (tests-manual/search-backend-mwe) so ADR 0064's
# gates move from "claimed" to "measured". Two triggers:
# a) manual -> workflow_dispatch (pick the entry count)
# b) on change of the MWE files -> push / pull_request filtered to their paths
name: Search backend MWEs

on:
workflow_dispatch:
inputs:
entries:
description: 'Number of synthetic entries to benchmark'
default: '100000'
type: string
push:
paths:
- 'tests-manual/search-backend-mwe/**'
- '.github/workflows/search-backend-mwe.yml'
pull_request:
paths:
- 'tests-manual/search-backend-mwe/**'
- '.github/workflows/search-backend-mwe.yml'

concurrency:
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
cancel-in-progress: true

permissions:
actions: write
contents: read

jobs:
sqlite-regex:
name: "SQLite regex gate"
# ubuntu-latest (not -slim): JBang provisions JDK 25 and sqlite-jdbc loads a native library.
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v6
with:
submodules: 'false'
show-progress: 'false'

- name: Generate JBang cache key
id: cache-key
shell: bash
run: echo "cache_key=jbang-search-mwe-$(date +%Y-%m)" >> "$GITHUB_OUTPUT"

- name: Cache ~/.jbang
uses: actions/cache@v5
with:
path: ~/.jbang
key: ${{ steps.cache-key.outputs.cache_key }}
restore-keys: jbang-search-mwe-

- name: Setup JBang
uses: jbangdev/setup-jbang@main

- name: Run the MWE suite
shell: bash
# workflow_dispatch -> the chosen size (default 100000); push/PR -> a smaller, faster set.
run: bash tests-manual/search-backend-mwe/run-suite.sh "${{ inputs.entries || '20000' }}"

- name: Show results
if: always()
shell: bash
run: |
for f in tests-manual/search-backend-mwe/out/*.csv; do
[ -e "$f" ] || continue
echo "== $f =="
cat "$f"
done

- name: Upload results CSV
if: always()
uses: actions/upload-artifact@v7
with:
name: search-backend-mwe-results
path: tests-manual/search-backend-mwe/out/*.csv
if-no-files-found: warn
145 changes: 145 additions & 0 deletions docs/decisions/0064-search-index-backend.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions docs/evaluations/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
nav_order: 8
has_children: true
---
# Evaluations

In-depth evaluations of technology alternatives for architectural decisions.

While a [decision record](../decisions/index.md) captures the *compressed* outcome of a decision, an evaluation document captures the *full* analysis of one option:
how it covers the [requirements](../requirements/index.md), its operational footprint, performance evidence, licensing, and migration effort.

Each evaluation links the requirements it assesses by their [OpenFastTrace](https://github.com/itsallcode/openfasttrace) requirement IDs, so that the chain *requirement → evaluation → decision* stays traceable.
207 changes: 207 additions & 0 deletions docs/evaluations/search-backend-in-memory.md

Large diffs are not rendered by default.

252 changes: 252 additions & 0 deletions docs/evaluations/search-backend-lucene.md

Large diffs are not rendered by default.

240 changes: 240 additions & 0 deletions docs/evaluations/search-backend-postgresql.md

Large diffs are not rendered by default.

273 changes: 273 additions & 0 deletions docs/evaluations/search-backend-sqlite.md

Large diffs are not rendered by default.

674 changes: 674 additions & 0 deletions docs/requirements/search-backend.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions tests-manual/search-backend-mwe/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Generated datasets and benchmark output -- reproducible from GenData.java, not committed.
data/
out/
*.csv
155 changes: 155 additions & 0 deletions tests-manual/search-backend-mwe/Common.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
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.Arrays;
import java.util.Locale;
import java.util.Optional;
import java.util.function.LongSupplier;

/// Shared helpers for the search-backend MWEs (ADR 0064 / issue #12708).
///
/// Plain named class (no `main`); pulled into each MWE via `//SOURCES Common.java`,
/// so every candidate emits the *same* CSV schema and is timed the *same* way.
/// These are deliberately MWE-grade; the committed numbers live in the #15385 JMH harness.
class Common {

/// One row of the uniform CSV every MWE prints. `-1` means "not applicable".
record Result(
String candidate, // sqlite | lucene | in-memory | embedded-pg
int datasetSize, // number of entries (not field rows)
String operation, // regex-query | substring-query | reindex | ...
String variant, // naive | trigram-prefilter | <query> | ...
double p50ms,
double p95ms,
long buildMs,
long indexBytes,
long matches, // result-set size, as a correctness sanity check
String notes) {

static String csvHeader() {
return "candidate,dataset_size,operation,variant,p50_ms,p95_ms,build_ms,index_bytes,matches,notes";
}

String toCsv() {
return String.join(",",
candidate,
Integer.toString(datasetSize),
csv(operation),
csv(variant),
fmt(p50ms), fmt(p95ms),
Long.toString(buildMs),
Long.toString(indexBytes),
Long.toString(matches),
csv(notes));
}

private static String fmt(double d) {
return d < 0 ? "" : String.format(Locale.ROOT, "%.3f", d);
}

private static String csv(String s) {
if (s == null) {
return "";
}
if (s.indexOf(',') >= 0 || s.indexOf('"') >= 0 || s.indexOf('\n') >= 0) {
return '"' + s.replace("\"", "\"\"") + '"';
}
return s;
}
}

record Timing(double p50ms, double p95ms, long matches) {}

Check failure on line 63 in tests-manual/search-backend-mwe/Common.java

View workflow job for this annotation

GitHub Actions / format

Formatting

Wrongly formatted. Run the formatter (see CONTRIBUTING.md) to fix in file tests-manual/search-backend-mwe/Common.java, line 63.

/// Run `body` `warmup` times (discarded) then `iters` times (timed); report p50/p95 in ms.
/// `body` returns a match count so callers can assert the two strategies agree.
static Timing time(int warmup, int iters, LongSupplier body) {
for (int i = 0; i < warmup; i++) {
body.getAsLong();
}
long[] ns = new long[iters];
long matches = 0;
for (int i = 0; i < iters; i++) {
long t0 = System.nanoTime();
matches = body.getAsLong();
ns[i] = System.nanoTime() - t0;
}
Arrays.sort(ns);
double p50 = ns[iters / 2] / 1_000_000.0;
double p95 = ns[(int) Math.min(iters - 1, Math.round(iters * 0.95))] / 1_000_000.0;
return new Timing(p50, p95, matches);
}

/// MWE-grade literal extraction: the longest run of non-meta characters in `regex`
/// that is guaranteed to appear verbatim in any match, so it can drive an FTS5 trigram
/// pre-filter. Returns empty when no run of >= 3 chars qualifies (the regex then has to
/// fall back to a full REGEXP scan -- exactly the honest weakness the ADR records).
///
/// Heuristic, not a regex parser: it skips escapes (`\d`), ignores character-class
/// contents (`[a-z]`), and drops a character made optional by a trailing `?`/`*`.
/// FTS5 trigram needs >= 3 characters, hence the threshold. The production pre-filter
/// would walk the parsed pattern; this is enough to measure the mechanism.
static Optional<String> longestLiteral(String regex) {
StringBuilder run = new StringBuilder();
String best = "";
boolean inClass = false;
for (int i = 0; i < regex.length(); i++) {
char c = regex.charAt(i);
if (inClass) {
if (c == ']') {
inClass = false;
}
continue;
}
char next = i + 1 < regex.length() ? regex.charAt(i + 1) : '\0';
if (c == '\\') { // escape -> ends the run, skip the escaped char
best = longer(best, run);
i++;
} else if (c == '[') { // character class -> ends the run, skip contents
best = longer(best, run);
inClass = true;
} else if (".(){}*+?|^$".indexOf(c) >= 0) { // metacharacter -> ends the run
best = longer(best, run);
} else if (next == '?' || next == '*') { // optional char -> not guaranteed present
best = longer(best, run);
} else {
run.append(c);
}
}
best = longer(best, run);
return best.length() >= 3 ? Optional.of(best) : Optional.empty();
}

private static String longer(String best, StringBuilder run) {
String r = run.toString();
run.setLength(0);
return r.length() > best.length() ? r : best;
}

/// Stream (entryId, field, value) rows from a generated EAV TSV (see GenData.java).
static void forEachField(Path tsv, FieldConsumer consumer) throws IOException {
try (BufferedReader br = Files.newBufferedReader(tsv, StandardCharsets.UTF_8)) {
String line;
while ((line = br.readLine()) != null) {
int t1 = line.indexOf('\t');
int t2 = line.indexOf('\t', t1 + 1);
if (t1 < 0 || t2 < 0) {
continue;
}
consumer.accept(line.substring(0, t1), line.substring(t1 + 1, t2), line.substring(t2 + 1));
}
}
}

@FunctionalInterface
interface FieldConsumer {
void accept(String entryId, String field, String value);
}

/// Parse the entry count out of a `bib-<N>.tsv` file name; -1 if not encoded.
static int datasetSizeFromName(Path tsv) {
var m = java.util.regex.Pattern.compile("bib-(\\d+)").matcher(tsv.getFileName().toString());
return m.find() ? Integer.parseInt(m.group(1)) : -1;
}
}
28 changes: 28 additions & 0 deletions tests-manual/search-backend-mwe/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Reproducible, pinned runner for the search-backend MWEs (ADR 0064 / issue #12708).
# It exists so the numbers do not depend on a particular dev laptop, and so the
# resource-constrained and arm64 claims can actually be exercised:
#
# docker build -t jabref-search-mwe .
# docker run --rm -v "$PWD/out:/mwe/out" jabref-search-mwe # full run
# docker run --rm -v "$PWD/out:/mwe/out" jabref-search-mwe 10000 # smaller dataset
# docker run --rm --cpus=2 --memory=2g -v "$PWD/out:/mwe/out" jabref-search-mwe # low-end machine
# docker buildx build --platform linux/arm64 -t jabref-search-mwe:arm64 . # linux-arm64
#
# Cross-platform for macOS/Windows is a GitHub Actions matrix job (jbang runs natively there);
# Docker only covers linux/amd64 and linux/arm64.
FROM eclipse-temurin:25-jdk

RUN apt-get update && apt-get install -y --no-install-recommends curl bash unzip \
&& rm -rf /var/lib/apt/lists/*

# jbang manages script deps; the base image already provides JDK 25 to satisfy //JAVA 25.
RUN curl -Ls https://sh.jbang.dev | bash
ENV PATH="/root/.jbang/bin:${PATH}"

WORKDIR /mwe
COPY *.java *.sh ./

# Warm the dependency cache (sqlite-jdbc, …) at build time so `docker run` is offline and reproducible.
RUN jbang build GenData.java SqliteRegex.java || true

ENTRYPOINT ["bash", "run-suite.sh"]
Loading
Loading