Skip to content

Commit 6ac05fc

Browse files
committed
GH-5691 CLI for running and storing query explanations
1 parent 05689ea commit 6ac05fc

93 files changed

Lines changed: 458 additions & 98 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

testsuites/benchmark-common/src/main/java/org/eclipse/rdf4j/benchmark/common/plan/QueryPlanCapture.java

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public final class QueryPlanCapture {
5353
public static final String FEATURE_PROPERTIES_PROPERTY = "rdf4j.query.plan.capture.featureProperties";
5454
public static final String FEATURE_PROPERTY_PREFIX_PROPERTY = "rdf4j.query.plan.capture.featurePropertyPrefix";
5555
public static final String GIT_COMMIT_PROPERTY = "rdf4j.query.plan.capture.gitCommit";
56+
public static final String GIT_BRANCH_PROPERTY = "rdf4j.query.plan.capture.gitBranch";
5657

5758
private static final DateTimeFormatter FILE_TIMESTAMP_FORMATTER = DateTimeFormatter
5859
.ofPattern("yyyyMMdd-HHmmssSSS")
@@ -115,6 +116,7 @@ public QueryPlanSnapshot capture(QueryPlanCaptureContext context,
115116
metadata.putIfAbsent("benchmark", context.getBenchmark());
116117
}
117118
metadata.putIfAbsent("gitCommit", resolveGitCommit());
119+
metadata.putIfAbsent("gitBranch", resolveGitBranch());
118120
metadata.putIfAbsent("javaVersion", System.getProperty("java.version", FeatureFlagCollector.NULL_VALUE));
119121

120122
FeatureFlagCollector collector = context.getFeatureFlagCollector();
@@ -241,28 +243,46 @@ private static String sanitizeForFileName(String input) {
241243
}
242244

243245
private static String resolveGitCommit() {
244-
String configured = System.getProperty(GIT_COMMIT_PROPERTY);
246+
String configured = resolveConfiguredValue(GIT_COMMIT_PROPERTY, "GIT_COMMIT");
247+
if (configured != null) {
248+
return configured;
249+
}
250+
return runGitCommand("rev-parse", "--verify", "HEAD");
251+
}
252+
253+
private static String resolveGitBranch() {
254+
String configured = resolveConfiguredValue(GIT_BRANCH_PROPERTY, "GIT_BRANCH");
255+
if (configured != null) {
256+
return configured;
257+
}
258+
return runGitCommand("rev-parse", "--abbrev-ref", "HEAD");
259+
}
260+
261+
private static String resolveConfiguredValue(String systemPropertyName, String environmentVariableName) {
262+
String configured = System.getProperty(systemPropertyName);
245263
if (configured != null && !configured.isBlank()) {
246264
return configured.trim();
247265
}
248-
249-
String fromEnvironment = System.getenv("GIT_COMMIT");
266+
String fromEnvironment = System.getenv(environmentVariableName);
250267
if (fromEnvironment != null && !fromEnvironment.isBlank()) {
251268
return fromEnvironment.trim();
252269
}
270+
return null;
271+
}
253272

273+
private static String runGitCommand(String... args) {
254274
Process process = null;
255275
try {
256-
process = new ProcessBuilder("git", "rev-parse", "--verify", "HEAD")
257-
.redirectErrorStream(true)
258-
.start();
276+
String[] command = new String[args.length + 1];
277+
command[0] = "git";
278+
System.arraycopy(args, 0, command, 1, args.length);
279+
process = new ProcessBuilder(command).redirectErrorStream(true).start();
259280
boolean finished = process.waitFor(2, TimeUnit.SECONDS);
260281
if (!finished || process.exitValue() != 0) {
261282
return "unknown";
262283
}
263284
try (InputStream stream = process.getInputStream()) {
264-
String output = new String(stream.readAllBytes(), StandardCharsets.UTF_8)
265-
.trim();
285+
String output = new String(stream.readAllBytes(), StandardCharsets.UTF_8).trim();
266286
return output.isEmpty() ? "unknown" : output;
267287
}
268288
} catch (Exception ignored) {

testsuites/benchmark-common/src/test/java/org/eclipse/rdf4j/benchmark/common/plan/QueryPlanCaptureTest.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,32 @@ void capturesAllExplanationLevelsAndIrRenderedQueries() throws IOException {
116116
assertEquals(outputFile.getFileName(), byFingerprint.get().getFileName());
117117
}
118118

119+
@Test
120+
void capturesGitBranchMetadataWhenConfigured() {
121+
String propertyKey = "rdf4j.query.plan.capture.gitBranch";
122+
String previousProperty = System.getProperty(propertyKey);
123+
try {
124+
System.setProperty(propertyKey, "feature/query-plan-cli");
125+
QueryPlanCapture capture = new QueryPlanCapture();
126+
String query = "SELECT ?s WHERE { ?s ?p ?o }";
127+
QueryPlanCaptureContext context = QueryPlanCaptureContext.builder()
128+
.outputDirectory(tempDir)
129+
.queryId("branch-capture")
130+
.queryString(query)
131+
.benchmark("QueryPlanCaptureTest")
132+
.build();
133+
134+
QueryPlanSnapshot snapshot = capture.capture(context, () -> stubTupleQueryFor(query));
135+
assertEquals("feature/query-plan-cli", snapshot.getMetadata().get("gitBranch"));
136+
} finally {
137+
if (previousProperty == null) {
138+
System.clearProperty(propertyKey);
139+
} else {
140+
System.setProperty(propertyKey, previousProperty);
141+
}
142+
}
143+
}
144+
119145
private static TupleQuery stubTupleQueryFor(String query) {
120146
EnumMap<Explanation.Level, Explanation> explanations = new EnumMap<>(Explanation.Level.class);
121147
for (Explanation.Level level : Explanation.Level.values()) {

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

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
import java.nio.charset.StandardCharsets;
1919
import java.nio.file.Files;
2020
import java.nio.file.Path;
21+
import java.time.Instant;
22+
import java.time.ZoneId;
23+
import java.time.format.DateTimeFormatter;
2124
import java.util.ArrayList;
2225
import java.util.LinkedHashSet;
2326
import java.util.List;
@@ -52,6 +55,7 @@ public final class QueryPlanSnapshotCli {
5255
private static final List<String> INTERACTIVE_ACTIONS = List.of(
5356
"run query",
5457
"compare existing runs",
58+
"rename runs by commit",
5559
"list themes",
5660
"list queries",
5761
"help");
@@ -61,6 +65,10 @@ public final class QueryPlanSnapshotCli {
6165
"executed");
6266
private static final String MANUAL_QUERY_ID_ENTRY = "<manual entry>";
6367
private static final String MANUAL_RUN_NAME_ENTRY = "<manual entry>";
68+
private static final String UNKNOWN_VALUE = "<unknown>";
69+
private static final ZoneId LOCAL_ZONE = ZoneId.systemDefault();
70+
private static final DateTimeFormatter LOCAL_TIME_FORMATTER = DateTimeFormatter
71+
.ofPattern("yyyy-MM-dd HH:mm:ss z");
6472
private static final long EXECUTION_REPEAT_SOFT_LIMIT_NANOS = TimeUnit.SECONDS.toNanos(60);
6573
private static final int EXECUTION_REPEAT_MIN_RUNS = 2;
6674
private static final int EXECUTION_REPEAT_MAX_RUNS = 128;
@@ -102,6 +110,10 @@ void run(QueryPlanSnapshotCliOptions options) throws Exception {
102110
printThemeQueries(resolved.listQueriesTheme);
103111
return;
104112
}
113+
if (resolved.renameRunsByCommit) {
114+
runRenameRunsByCommit(resolved);
115+
return;
116+
}
105117
if (resolved.compareExisting) {
106118
runCompareExisting(resolved);
107119
return;
@@ -127,7 +139,7 @@ private QueryPlanSnapshotCliOptions resolveInteractiveTopLevelAction(QueryPlanSn
127139
throws IOException {
128140
QueryPlanSnapshotCliOptions resolved = options.copy();
129141
if (resolved.argumentCount != 0 || resolved.noInteractive || resolved.help || resolved.listThemes
130-
|| resolved.listQueriesTheme != null || resolved.compareExisting) {
142+
|| resolved.listQueriesTheme != null || resolved.compareExisting || resolved.renameRunsByCommit) {
131143
return resolved;
132144
}
133145

@@ -139,6 +151,10 @@ private QueryPlanSnapshotCliOptions resolveInteractiveTopLevelAction(QueryPlanSn
139151
resolved.compareExisting = true;
140152
return resolved;
141153
}
154+
if ("rename runs by commit".equals(action)) {
155+
resolved.renameRunsByCommit = true;
156+
return resolved;
157+
}
142158
if ("list themes".equals(action)) {
143159
resolved.listThemes = true;
144160
return resolved;
@@ -316,6 +332,90 @@ private void runCompareExisting(QueryPlanSnapshotCliOptions options) throws Exce
316332
runInteractiveRunBrowser(matchingRuns, resolved.diffMode);
317333
}
318334

335+
private void runRenameRunsByCommit(QueryPlanSnapshotCliOptions options) throws Exception {
336+
Path outputDirectory = resolveRenameOutputDirectory(options);
337+
QueryPlanCapture capture = new QueryPlanCapture();
338+
List<QueryPlanSnapshotComparator.SnapshotRun> allRuns = QueryPlanSnapshotComparator.loadRuns(outputDirectory,
339+
capture);
340+
List<CommitRunGroup> groups = groupRunsByCommit(allRuns);
341+
if (groups.isEmpty()) {
342+
output.println("No runs with gitCommit metadata found in " + outputDirectory.toAbsolutePath());
343+
return;
344+
}
345+
346+
int selectedIndex = promptChoiceIndex("Select commit", toCommitChoices(groups));
347+
CommitRunGroup selectedGroup = groups.get(selectedIndex);
348+
String newRunName = promptRequiredValue("New run name");
349+
int updatedRuns = renameRunsForCommit(allRuns, selectedGroup.commit(), newRunName, capture);
350+
output.println("Renamed " + updatedRuns + " run(s) for commit " + selectedGroup.commit()
351+
+ " to runName=" + newRunName);
352+
}
353+
354+
private Path resolveRenameOutputDirectory(QueryPlanSnapshotCliOptions options) throws IOException {
355+
if (options.outputDirectory != null) {
356+
return options.outputDirectory;
357+
}
358+
Path prompted = promptOptionalPath("Output directory (blank uses default)");
359+
return prompted != null ? prompted : QueryPlanCapture.resolveOutputDirectory();
360+
}
361+
362+
private List<CommitRunGroup> groupRunsByCommit(List<QueryPlanSnapshotComparator.SnapshotRun> runs) {
363+
Map<String, CommitRunGroup> byCommit = new java.util.LinkedHashMap<>();
364+
for (QueryPlanSnapshotComparator.SnapshotRun run : runs) {
365+
Map<String, String> metadata = run.snapshot().getMetadata();
366+
if (metadata == null) {
367+
continue;
368+
}
369+
String commit = normalizedOrNull(metadata.get("gitCommit"));
370+
if (commit == null) {
371+
continue;
372+
}
373+
String branch = normalizedOrNull(metadata.get("gitBranch"));
374+
byCommit.computeIfAbsent(commit, CommitRunGroup::new).recordRun(run, branch);
375+
}
376+
377+
List<CommitRunGroup> groups = new ArrayList<>(byCommit.values());
378+
groups.sort((left, right) -> {
379+
int byTime = Long.compare(right.latestEpochMillis(), left.latestEpochMillis());
380+
return byTime != 0 ? byTime : left.commit().compareTo(right.commit());
381+
});
382+
return groups;
383+
}
384+
385+
private List<String> toCommitChoices(List<CommitRunGroup> groups) {
386+
List<String> choices = new ArrayList<>(groups.size());
387+
for (CommitRunGroup group : groups) {
388+
choices.add("commit=" + group.commit()
389+
+ " branch=" + group.branchSummary()
390+
+ " localTime=" + formatLocalTime(group.latestCapturedAt())
391+
+ " runs=" + group.runCount());
392+
}
393+
return choices;
394+
}
395+
396+
private int renameRunsForCommit(List<QueryPlanSnapshotComparator.SnapshotRun> runs, String commit,
397+
String newRunName, QueryPlanCapture capture) throws IOException {
398+
int updatedRuns = 0;
399+
for (QueryPlanSnapshotComparator.SnapshotRun run : runs) {
400+
Map<String, String> metadata = run.snapshot().getMetadata();
401+
if (metadata == null || !commit.equals(normalizedOrNull(metadata.get("gitCommit")))) {
402+
continue;
403+
}
404+
Path snapshotPath = run.path();
405+
if (snapshotPath == null) {
406+
continue;
407+
}
408+
409+
QueryPlanSnapshot snapshot = run.snapshot();
410+
java.util.LinkedHashMap<String, String> updatedMetadata = new java.util.LinkedHashMap<>(metadata);
411+
updatedMetadata.put("runName", newRunName);
412+
snapshot.setMetadata(updatedMetadata);
413+
capture.writeSnapshot(snapshotPath, snapshot);
414+
updatedRuns++;
415+
}
416+
return updatedRuns;
417+
}
418+
319419
private void runInteractiveRunBrowser(List<QueryPlanSnapshotComparator.SnapshotRun> runs,
320420
QueryPlanSnapshotCliOptions.DiffMode diffMode) throws IOException {
321421
while (true) {
@@ -563,6 +663,16 @@ private QueryPlanSnapshotCliOptions.ComparisonPair resolveComparisonPair(QueryPl
563663
}
564664
}
565665

666+
private String promptRequiredValue(String message) throws IOException {
667+
while (true) {
668+
String value = prompt(message);
669+
if (!value.isBlank()) {
670+
return value;
671+
}
672+
output.println(message + " cannot be blank.");
673+
}
674+
}
675+
566676
private void promptForQueryInput(QueryPlanSnapshotCliOptions options) throws IOException {
567677
String mode = promptChoice("Query source [themed|manual|file|all-themed]",
568678
List.of("themed", "manual", "file", "all-themed"));
@@ -1023,6 +1133,28 @@ private static String normalizedOrNull(String value) {
10231133
return normalized.isEmpty() ? null : normalized;
10241134
}
10251135

1136+
private static String formatLocalTime(String capturedAt) {
1137+
if (capturedAt == null || capturedAt.isBlank()) {
1138+
return UNKNOWN_VALUE;
1139+
}
1140+
try {
1141+
return LOCAL_TIME_FORMATTER.format(Instant.parse(capturedAt).atZone(LOCAL_ZONE));
1142+
} catch (Exception ignored) {
1143+
return capturedAt;
1144+
}
1145+
}
1146+
1147+
private static long toEpochMillis(String capturedAt) {
1148+
if (capturedAt == null || capturedAt.isBlank()) {
1149+
return Long.MIN_VALUE;
1150+
}
1151+
try {
1152+
return Instant.parse(capturedAt).toEpochMilli();
1153+
} catch (Exception ignored) {
1154+
return Long.MIN_VALUE;
1155+
}
1156+
}
1157+
10261158
private static String formatQueryTimeoutSeconds(Integer queryTimeoutSeconds) {
10271159
if (queryTimeoutSeconds == null || queryTimeoutSeconds == 0) {
10281160
return "<none>";
@@ -1186,6 +1318,49 @@ private QueryExecutionVerification(int runs, long elapsedNanos, long resultCount
11861318
}
11871319
}
11881320

1321+
private static final class CommitRunGroup {
1322+
private final String commit;
1323+
private final LinkedHashSet<String> branches = new LinkedHashSet<>();
1324+
private long latestEpochMillis = Long.MIN_VALUE;
1325+
private String latestCapturedAt;
1326+
private int runCount;
1327+
1328+
private CommitRunGroup(String commit) {
1329+
this.commit = commit;
1330+
}
1331+
1332+
private void recordRun(QueryPlanSnapshotComparator.SnapshotRun run, String branch) {
1333+
runCount++;
1334+
branches.add(branch == null ? UNKNOWN_VALUE : branch);
1335+
String capturedAt = run.snapshot().getCapturedAt();
1336+
long epochMillis = toEpochMillis(capturedAt);
1337+
if (epochMillis >= latestEpochMillis) {
1338+
latestEpochMillis = epochMillis;
1339+
latestCapturedAt = capturedAt;
1340+
}
1341+
}
1342+
1343+
private String commit() {
1344+
return commit;
1345+
}
1346+
1347+
private String branchSummary() {
1348+
return String.join(",", branches);
1349+
}
1350+
1351+
private long latestEpochMillis() {
1352+
return latestEpochMillis;
1353+
}
1354+
1355+
private String latestCapturedAt() {
1356+
return latestCapturedAt;
1357+
}
1358+
1359+
private int runCount() {
1360+
return runCount;
1361+
}
1362+
}
1363+
11891364
static QueryPlanSnapshotCliOptions parseArgs(String[] args) {
11901365
return QueryPlanSnapshotCliOptions.parseArgs(args);
11911366
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ final class QueryPlanSnapshotCliOptions {
3232
boolean listThemes;
3333
Theme listQueriesTheme;
3434
boolean compareExisting;
35+
boolean renameRunsByCommit;
3536
boolean compareLatest;
3637
boolean runAllThemeQueries;
3738
boolean persist = true;
@@ -62,6 +63,7 @@ QueryPlanSnapshotCliOptions copy() {
6263
copy.listThemes = listThemes;
6364
copy.listQueriesTheme = listQueriesTheme;
6465
copy.compareExisting = compareExisting;
66+
copy.renameRunsByCommit = renameRunsByCommit;
6567
copy.compareLatest = compareLatest;
6668
copy.runAllThemeQueries = runAllThemeQueries;
6769
copy.persist = persist;
@@ -95,7 +97,7 @@ boolean hasComparisonFilter() {
9597
}
9698

9799
boolean isRunMode() {
98-
return !compareExisting;
100+
return !compareExisting && !renameRunsByCommit;
99101
}
100102

101103
static boolean requiresInteractiveInput(QueryPlanSnapshotCliOptions options) {

testsuites/benchmark/src/main/resources/plan/cli/lmdb/lmdb-electrical_grid-q0-8608b9dfc996a3a316478c1deb02c8910fe964ce708b8c9b74fda608fa544870-20260218-002758468-35efe30e.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"expectedCount" : "1",
1414
"benchmark" : "QueryPlanSnapshotCli",
1515
"gitCommit" : "bfa2aecf22fe98823de3cb7aafd7184928fe4a5f",
16-
"javaVersion" : "25.0.1"
16+
"javaVersion" : "25.0.1",
17+
"runName" : "main-2026-02-18"
1718
},
1819
"featureFlags" : {
1920
"cli.store" : "lmdb",

testsuites/benchmark/src/main/resources/plan/cli/lmdb/lmdb-electrical_grid-q1-1f2d956ec3d9141c52dad66a35978f292b33adbfbcab4a5d03dbc2c220e346c4-20260218-002803896-c9b3dab0.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"expectedCount" : "1",
1414
"benchmark" : "QueryPlanSnapshotCli",
1515
"gitCommit" : "bfa2aecf22fe98823de3cb7aafd7184928fe4a5f",
16-
"javaVersion" : "25.0.1"
16+
"javaVersion" : "25.0.1",
17+
"runName" : "main-2026-02-18"
1718
},
1819
"featureFlags" : {
1920
"cli.store" : "lmdb",

0 commit comments

Comments
 (0)