Skip to content

Commit 665e95c

Browse files
committed
GH-5691 CLI for running and storing query explanations
1 parent 1ff6db0 commit 665e95c

4 files changed

Lines changed: 200 additions & 18 deletions

File tree

testsuites/benchmark/README.md

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ Scope:
1010
- Metadata + feature-flag state for reproducibility.
1111
- Smart semantic diff modes.
1212

13+
Status:
14+
- Experimental CLI and capture APIs (`@Experimental`).
15+
1316
## What You Get
1417

1518
Each snapshot stores:
@@ -24,6 +27,9 @@ Each snapshot stores:
2427
- IR-rendered query (optimized/executed by default)
2528
- Metadata (git commit, benchmark/store/theme context, custom keys).
2629
- Feature flags (system properties, direct values, reflection probes).
30+
- Execution verification summary printed by CLI:
31+
- repeated query executions
32+
- dynamic run count with a soft 60-second cap per query
2733

2834
## Where Things Live
2935

@@ -59,7 +65,7 @@ Interactive menu behavior:
5965
- `list themes`
6066
- `list queries`
6167
- `help`
62-
- Run mode prompt order starts with: `store` -> `query source` (`themed`, `manual`, or `file`) -> remaining fields.
68+
- Run mode prompt order starts with: `store` -> `query source` (`themed`, `manual`, `file`, or `all-themed`) -> remaining fields.
6369
- No-argument run wizard also prompts optional CLI flags: persist, compare-latest, diff-mode, query-id, output-dir, lmdb-data-dir (for LMDB).
6470

6571
Show help:
@@ -115,7 +121,7 @@ Interactive selection (browse/view/compare):
115121
```
116122

117123
Interactive run browser actions:
118-
- `view run`: pick a run and print full details (captured time, query id/fingerprint, metadata, feature flags, per-level explanation availability).
124+
- `view run`: pick a run and print full details, original query, and full per-level explanation blocks (including IR-rendered query text when present).
119125
- `compare runs`: pick left/right runs and print semantic diff.
120126
- `quit`: exit browser.
121127

@@ -156,6 +162,44 @@ Useful for ad-hoc compare-only checks:
156162
... -Dexec.args="--store memory --theme MEDICAL_RECORDS --query-index 0 --no-persist --compare-latest"
157163
```
158164

165+
Every CLI run also prints:
166+
167+
- `=== Execution Verification ===`
168+
- `runs`, `totalMillis`, `averageMillis`, `resultCount`
169+
- `softLimitMillis` (currently `60000`)
170+
- whether stopping hit the soft-limit projection or max repeat-run cap
171+
172+
### 7) Run all themed queries across all themes for one store
173+
174+
Memory store:
175+
176+
```bash
177+
... -Dexec.args="--store memory --all-theme-queries"
178+
```
179+
180+
LMDB store:
181+
182+
```bash
183+
... -Dexec.args="--store lmdb --all-theme-queries"
184+
```
185+
186+
With in-memory-only capture:
187+
188+
```bash
189+
... -Dexec.args="--store memory --all-theme-queries --no-persist"
190+
```
191+
192+
With compare-latest per query run:
193+
194+
```bash
195+
... -Dexec.args="--store lmdb --all-theme-queries --compare-latest --diff-mode structure+estimates"
196+
```
197+
198+
Notes:
199+
- `--all-theme-queries` is run mode only (not compare mode).
200+
- Do not combine `--all-theme-queries` with single-query selectors (`--theme`, `--theme-query`, `--query-index`, `--query`, `--query-file`).
201+
- In interactive mode this is available via query source `all-themed`.
202+
159203
## Smart Diff Modes
160204

161205
Set with `--diff-mode`:
@@ -347,6 +391,7 @@ This path stores themed benchmark artifacts without CLI wrapper.
347391
- `--no-persist`
348392
- `--compare-latest`
349393
- `--compare-existing`
394+
- `--all-theme-queries`
350395
- `--query-id <id>`
351396
- `--fingerprint <hash>`
352397
- `--compare-indices <i,j>`

testsuites/benchmark/src/main/java/org/eclipse/rdf4j/benchmark/plan/QueryPlanSnapshotCli.java

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.Locale;
2525
import java.util.Map;
2626
import java.util.Objects;
27+
import java.util.concurrent.TimeUnit;
2728

2829
import org.eclipse.rdf4j.benchmark.common.BenchmarkQuery;
2930
import org.eclipse.rdf4j.benchmark.common.ThemeQueryCatalog;
@@ -56,6 +57,9 @@ public final class QueryPlanSnapshotCli {
5657
"optimized",
5758
"executed");
5859
private static final String MANUAL_QUERY_ID_ENTRY = "<manual entry>";
60+
private static final long EXECUTION_REPEAT_SOFT_LIMIT_NANOS = TimeUnit.SECONDS.toNanos(60);
61+
private static final int EXECUTION_REPEAT_MIN_RUNS = 2;
62+
private static final int EXECUTION_REPEAT_MAX_RUNS = 128;
5963

6064
public static void main(String[] args) throws Exception {
6165
QueryPlanSnapshotCliOptions options = parseArgs(args);
@@ -164,6 +168,7 @@ private void runSingleQueryCapture(QueryPlanSnapshotCliOptions options,
164168

165169
QueryPlanSnapshot currentSnapshot;
166170
Path snapshotPath = null;
171+
QueryExecutionVerification executionVerification;
167172
try (SailRepositoryConnection connection = storeRuntime.repository.getConnection()) {
168173
if (options.persist) {
169174
snapshotPath = capture.captureAndWrite(context, () -> connection.prepareTupleQuery(queryText));
@@ -173,10 +178,12 @@ private void runSingleQueryCapture(QueryPlanSnapshotCliOptions options,
173178
currentSnapshot = capture.capture(context, () -> connection.prepareTupleQuery(queryText));
174179
output.println("Snapshot captured in-memory only (--persist=false).");
175180
}
181+
executionVerification = verifyRepeatedExecution(connection, queryText);
176182
}
177183

178184
printResultsSection(options, queryId, queryText);
179185
printPrettyExplanations(currentSnapshot);
186+
printExecutionVerification(executionVerification);
180187

181188
if (options.compareLatest) {
182189
compareWithLatest(outputDirectory, queryId, currentSnapshot, snapshotPath, capture, options.diffMode);
@@ -212,6 +219,7 @@ private void runAllThemeQueriesCapture(QueryPlanSnapshotCliOptions options,
212219

213220
QueryPlanSnapshot currentSnapshot;
214221
Path snapshotPath = null;
222+
QueryExecutionVerification executionVerification;
215223
try (SailRepositoryConnection connection = storeRuntime.repository.getConnection()) {
216224
if (options.persist) {
217225
snapshotPath = capture.captureAndWrite(context, () -> connection.prepareTupleQuery(queryText));
@@ -221,6 +229,7 @@ private void runAllThemeQueriesCapture(QueryPlanSnapshotCliOptions options,
221229
currentSnapshot = capture.capture(context, () -> connection.prepareTupleQuery(queryText));
222230
output.println("Snapshot captured in-memory only (--persist=false).");
223231
}
232+
executionVerification = verifyRepeatedExecution(connection, queryText);
224233
}
225234

226235
output.println();
@@ -229,6 +238,7 @@ private void runAllThemeQueriesCapture(QueryPlanSnapshotCliOptions options,
229238
"Theme=" + theme + ", QueryIndex=" + queryIndex + ", QueryName=" + benchmarkQuery.getName());
230239
printResultsSection(perQueryOptions, queryId, queryText);
231240
printPrettyExplanations(currentSnapshot);
241+
printExecutionVerification(executionVerification);
232242

233243
if (options.compareLatest) {
234244
compareWithLatest(outputDirectory, queryId, currentSnapshot, snapshotPath, capture,
@@ -942,6 +952,84 @@ private void printExplanation(String levelKey, QueryPlanExplanation explanation)
942952
}
943953
}
944954

955+
private QueryExecutionVerification verifyRepeatedExecution(SailRepositoryConnection connection, String queryText) {
956+
long elapsedNanos = 0;
957+
long stableResultCount = Long.MIN_VALUE;
958+
int runs = 0;
959+
boolean softLimitReached = false;
960+
961+
while (runs < EXECUTION_REPEAT_MAX_RUNS) {
962+
if (runs >= EXECUTION_REPEAT_MIN_RUNS) {
963+
long averageNanos = Math.max(1L, elapsedNanos / runs);
964+
if (elapsedNanos + averageNanos > EXECUTION_REPEAT_SOFT_LIMIT_NANOS) {
965+
softLimitReached = true;
966+
break;
967+
}
968+
} else if (elapsedNanos >= EXECUTION_REPEAT_SOFT_LIMIT_NANOS) {
969+
softLimitReached = true;
970+
break;
971+
}
972+
973+
long startedAt = System.nanoTime();
974+
long currentResultCount = connection.prepareTupleQuery(queryText).evaluate().stream().count();
975+
long runNanos = Math.max(1L, System.nanoTime() - startedAt);
976+
elapsedNanos += runNanos;
977+
runs++;
978+
979+
if (stableResultCount == Long.MIN_VALUE) {
980+
stableResultCount = currentResultCount;
981+
} else if (stableResultCount != currentResultCount) {
982+
throw new IllegalStateException("Result count changed between repeated runs: expected "
983+
+ stableResultCount + " but got " + currentResultCount + " on run " + runs);
984+
}
985+
}
986+
987+
boolean maxRunsReached = runs >= EXECUTION_REPEAT_MAX_RUNS;
988+
if (runs == 0) {
989+
return new QueryExecutionVerification(0, 0, 0, softLimitReached, maxRunsReached);
990+
}
991+
992+
return new QueryExecutionVerification(runs, elapsedNanos, stableResultCount, softLimitReached,
993+
maxRunsReached);
994+
}
995+
996+
private void printExecutionVerification(QueryExecutionVerification executionVerification) {
997+
output.println();
998+
output.println("=== Execution Verification ===");
999+
if (executionVerification.runs == 0) {
1000+
output.println("No repeated runs executed.");
1001+
return;
1002+
}
1003+
1004+
long totalMillis = TimeUnit.NANOSECONDS.toMillis(executionVerification.elapsedNanos);
1005+
long averageMillis = TimeUnit.NANOSECONDS.toMillis(
1006+
executionVerification.elapsedNanos / executionVerification.runs);
1007+
output.println("runs=" + executionVerification.runs
1008+
+ ", totalMillis=" + totalMillis
1009+
+ ", averageMillis=" + averageMillis
1010+
+ ", resultCount=" + executionVerification.resultCount
1011+
+ ", softLimitMillis=" + TimeUnit.NANOSECONDS.toMillis(EXECUTION_REPEAT_SOFT_LIMIT_NANOS)
1012+
+ ", softLimitReached=" + executionVerification.softLimitReached
1013+
+ ", maxRunsReached=" + executionVerification.maxRunsReached);
1014+
}
1015+
1016+
private static final class QueryExecutionVerification {
1017+
private final int runs;
1018+
private final long elapsedNanos;
1019+
private final long resultCount;
1020+
private final boolean softLimitReached;
1021+
private final boolean maxRunsReached;
1022+
1023+
private QueryExecutionVerification(int runs, long elapsedNanos, long resultCount, boolean softLimitReached,
1024+
boolean maxRunsReached) {
1025+
this.runs = runs;
1026+
this.elapsedNanos = elapsedNanos;
1027+
this.resultCount = resultCount;
1028+
this.softLimitReached = softLimitReached;
1029+
this.maxRunsReached = maxRunsReached;
1030+
}
1031+
}
1032+
9451033
static QueryPlanSnapshotCliOptions parseArgs(String[] args) {
9461034
return QueryPlanSnapshotCliOptions.parseArgs(args);
9471035
}

testsuites/benchmark/src/main/java/org/eclipse/rdf4j/benchmark/plan/QueryPlanSnapshotComparator.java

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
import java.nio.file.Path;
1919
import java.time.Instant;
2020
import java.util.ArrayList;
21+
import java.util.LinkedHashSet;
2122
import java.util.List;
23+
import java.util.Locale;
2224
import java.util.Map;
2325
import java.util.Objects;
2426
import java.util.TreeSet;
@@ -98,35 +100,61 @@ static void printRunDetails(PrintStream out, SnapshotRun run) {
98100
out.println(" capturedAt: " + normalize(snapshot.getCapturedAt()));
99101
out.println(" queryId: " + normalize(snapshot.getQueryId()));
100102
out.println(" unoptimizedFingerprint: " + normalize(snapshot.getUnoptimizedFingerprint()));
101-
out.println(" queryString:");
102-
printTextBlock(out, snapshot.getQueryString(), " ");
103103
if (run.path != null) {
104104
out.println(" path: " + run.path.toAbsolutePath());
105105
}
106-
107106
printStringMap(out, "metadata", snapshot.getMetadata());
108107
printStringMap(out, "featureFlags", snapshot.getFeatureFlags());
109108

110-
Map<String, QueryPlanExplanation> explanations = snapshot.getExplanations();
111-
out.println(" explanations:");
109+
out.println();
110+
out.println("=== Results ===");
111+
out.println("Original query:");
112+
printTextBlock(out, snapshot.getQueryString(), "");
113+
printFullExplanations(out, snapshot.getExplanations());
114+
}
115+
116+
private static void printFullExplanations(PrintStream out, Map<String, QueryPlanExplanation> explanations) {
112117
if (explanations == null || explanations.isEmpty()) {
113-
out.println(" <none>");
118+
out.println();
119+
out.println("No query explanations captured.");
114120
return;
115121
}
116122

117-
for (Map.Entry<String, QueryPlanExplanation> entry : explanations.entrySet()) {
118-
QueryPlanExplanation explanation = entry.getValue();
119-
out.println(" " + entry.getKey() + ":");
123+
LinkedHashSet<String> orderedLevels = new LinkedHashSet<>(List.of("unoptimized", "optimized", "executed"));
124+
orderedLevels.addAll(explanations.keySet());
125+
for (String levelKey : orderedLevels) {
126+
QueryPlanExplanation explanation = explanations.get(levelKey);
120127
if (explanation == null) {
121-
out.println(" <null>");
122128
continue;
123129
}
124-
out.println(" tupleExprJson: " + (isPresent(explanation.getTupleExprJson()) ? "present" : "missing"));
125-
out.println(
126-
" irRenderedQuery: " + (isPresent(explanation.getIrRenderedQuery()) ? "present" : "missing"));
127-
out.println(" explanationText:");
128-
printTextBlock(out, explanation.getExplanationText(), " ");
130+
131+
out.println();
132+
out.println("=== " + displayLevelName(levelKey, explanation.getLevel()) + " Explanation ===");
133+
String explanationText = explanation.getExplanationText();
134+
if (isPresent(explanationText)) {
135+
printTextBlock(out, explanationText, "");
136+
} else {
137+
out.println("(no explanation text)");
138+
}
139+
140+
if (isPresent(explanation.getIrRenderedQuery())) {
141+
out.println("--- IR Rendered Query ---");
142+
printTextBlock(out, explanation.getIrRenderedQuery(), "");
143+
}
144+
if (isPresent(explanation.getIrRenderingError())) {
145+
out.println("--- IR Rendering Error ---");
146+
printTextBlock(out, explanation.getIrRenderingError(), "");
147+
}
148+
}
149+
}
150+
151+
private static String displayLevelName(String levelKey, String levelValue) {
152+
String base = isPresent(levelValue) ? levelValue : levelKey;
153+
if (!isPresent(base)) {
154+
return "Unknown";
129155
}
156+
String lower = base.toLowerCase(Locale.ROOT);
157+
return lower.substring(0, 1).toUpperCase(Locale.ROOT) + lower.substring(1);
130158
}
131159

132160
static void printComparison(PrintStream out, SnapshotRun left, SnapshotRun right) {

testsuites/benchmark/src/test/java/org/eclipse/rdf4j/benchmark/plan/QueryPlanSnapshotCliTest.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,27 @@ void runModePrintsPrettyExplanationForAllLevels() throws Exception {
194194
assertTrue(executedIndex > optimizedIndex);
195195
}
196196

197+
@Test
198+
void runModePrintsExecutionVerificationSummary() throws Exception {
199+
ByteArrayOutputStream outputBuffer = new ByteArrayOutputStream();
200+
QueryPlanSnapshotCli cli = new QueryPlanSnapshotCli(new BufferedReader(new StringReader("")),
201+
new PrintStream(outputBuffer, true, StandardCharsets.UTF_8.name()));
202+
203+
QueryPlanSnapshotCliOptions options = QueryPlanSnapshotCli.parseArgs(new String[] {
204+
"--no-interactive",
205+
"--store", "memory",
206+
"--theme", "MEDICAL_RECORDS",
207+
"--query-index", "0",
208+
"--persist", "false"
209+
});
210+
211+
cli.run(options);
212+
213+
String printed = outputBuffer.toString(StandardCharsets.UTF_8);
214+
assertTrue(printed.contains("=== Execution Verification ==="), printed);
215+
assertTrue(printed.contains("runs="), printed);
216+
}
217+
197218
@Test
198219
void runModePrintsOriginalQueryAtStartOfResultsSection() throws Exception {
199220
String query = "SELECT * WHERE { ?s ?p ?o } LIMIT 5";
@@ -242,7 +263,7 @@ void interactiveRunAsksForQuerySourceBeforeTheme() throws Exception {
242263

243264
String printed = outputBuffer.toString(StandardCharsets.UTF_8);
244265
int storeIndex = printed.indexOf("Store [memory|lmdb]");
245-
int querySourceIndex = printed.indexOf("Query source [themed|manual|file]");
266+
int querySourceIndex = printed.indexOf("Query source [themed|manual|file|all-themed]");
246267
int themeIndex = printed.indexOf("Theme");
247268
assertTrue(storeIndex >= 0);
248269
assertTrue(querySourceIndex > storeIndex);

0 commit comments

Comments
 (0)