From 26849478c44cadcdfb1203205c6fb50b56774745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ha=CC=8Avard=20Ottestad?= Date: Sat, 28 Feb 2026 16:29:08 +0100 Subject: [PATCH 1/3] test: GH-0000 add exhaustive lmdb index selection combinatorics test --- ...eStoreIndexSelectionCombinatoricsTest.java | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreIndexSelectionCombinatoricsTest.java diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreIndexSelectionCombinatoricsTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreIndexSelectionCombinatoricsTest.java new file mode 100644 index 0000000000..10ab5b2a0b --- /dev/null +++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreIndexSelectionCombinatoricsTest.java @@ -0,0 +1,234 @@ +/******************************************************************************* + * Copyright (c) 2026 Eclipse RDF4J contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Distribution License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + *******************************************************************************/ +// Some portions generated by Codex +package org.eclipse.rdf4j.sail.lmdb; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import java.io.File; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +import org.eclipse.rdf4j.sail.lmdb.config.LmdbStoreConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Exhaustive combinatorics test for {@link TripleStore#getBestIndex(long, long, long, long)}. + */ +public class TripleStoreIndexSelectionCombinatoricsTest { + + private static final int MAX_CONFIGURED_INDEXES = 6; + + @Test + public void selectsMaxScoreIndexForEveryPatternAndConfiguredIndexCombination(@TempDir File dataDir) + throws Exception { + List allIndexSpecs = allIndexSpecs(); + Map detachedIndexes = createDetachedIndexes(dataDir, allIndexSpecs); + List statementPatterns = allStatementPatterns(); + + File selectorDir = new File(dataDir, "selector"); + selectorDir.mkdirs(); + + try (TripleStore selectorStore = new TripleStore(selectorDir, new LmdbStoreConfig("spoc"), null)) { + List selectorIndexes = indexes(selectorStore); + List originalSelectorIndexes = new ArrayList<>(selectorIndexes); + long[] configuredCombinationCount = new long[1]; + long[] checkedSelections = new long[1]; + + try { + forEachConfiguredIndexCombination(allIndexSpecs, configuredIndexSpecs -> { + configuredCombinationCount[0]++; + selectorIndexes.clear(); + for (String indexSpec : configuredIndexSpecs) { + selectorIndexes.add(detachedIndexes.get(indexSpec)); + } + + for (long[] pattern : statementPatterns) { + TripleStore.TripleIndex selected = selectorStore.getBestIndex(pattern[0], pattern[1], pattern[2], + pattern[3]); + String selectedSpec = new String(selected.getFieldSeq()); + int selectedScore = patternScore(selectedSpec, pattern); + + int bestScore = Integer.MIN_VALUE; + Set bestScoringSpecs = new LinkedHashSet<>(); + for (String configuredIndexSpec : configuredIndexSpecs) { + int score = patternScore(configuredIndexSpec, pattern); + if (score > bestScore) { + bestScore = score; + bestScoringSpecs.clear(); + bestScoringSpecs.add(configuredIndexSpec); + } else if (score == bestScore) { + bestScoringSpecs.add(configuredIndexSpec); + } + } + + if (selectedScore != bestScore || !bestScoringSpecs.contains(selectedSpec)) { + fail("Incorrect index selection for pattern " + patternLabel(pattern) + + " and configured indexes " + configuredIndexSpecs + ": selected=" + selectedSpec + + " (score=" + selectedScore + "), best=" + bestScoringSpecs + " (score=" + + bestScore + ")"); + } + + checkedSelections[0]++; + } + }); + } finally { + selectorIndexes.clear(); + selectorIndexes.addAll(originalSelectorIndexes); + } + + assertEquals(expectedCombinationCount(allIndexSpecs.size(), MAX_CONFIGURED_INDEXES), + configuredCombinationCount[0]); + assertEquals(configuredCombinationCount[0] * statementPatterns.size(), checkedSelections[0]); + } + } + + private static Map createDetachedIndexes(File dataDir, List indexSpecs) + throws Exception { + Map indexes = new LinkedHashMap<>(); + for (String indexSpec : indexSpecs) { + File donorDir = new File(dataDir, "donor-" + indexSpec); + try (TripleStore donorStore = new TripleStore(donorDir, new LmdbStoreConfig(indexSpec), null)) { + indexes.put(indexSpec, donorStore.getBestIndex(-1, -1, -1, -1)); + } + } + return indexes; + } + + private static List allStatementPatterns() { + List patterns = new ArrayList<>(16); + for (int mask = 0; mask < 16; mask++) { + long subj = (mask & 0b0001) == 0 ? -1 : 11; + long pred = (mask & 0b0010) == 0 ? -1 : 13; + long obj = (mask & 0b0100) == 0 ? -1 : 17; + long context = (mask & 0b1000) == 0 ? -1 : 0; + patterns.add(new long[] { subj, pred, obj, context }); + } + return patterns; + } + + private static String patternLabel(long[] pattern) { + return "(" + componentLabel("s", pattern[0]) + ", " + componentLabel("p", pattern[1]) + ", " + + componentLabel("o", pattern[2]) + ", " + componentLabel("c", pattern[3]) + ")"; + } + + private static String componentLabel(String name, long value) { + return value >= 0 ? name + "=" + value : name + "=*"; + } + + private static int patternScore(String indexSpec, long[] pattern) { + int score = 0; + for (int i = 0; i < indexSpec.length(); i++) { + if (isBound(pattern, indexSpec.charAt(i))) { + score++; + } else { + return score; + } + } + return score; + } + + private static boolean isBound(long[] pattern, char component) { + switch (component) { + case 's': + return pattern[0] >= 0; + case 'p': + return pattern[1] >= 0; + case 'o': + return pattern[2] >= 0; + case 'c': + return pattern[3] >= 0; + default: + throw new IllegalArgumentException("Unknown component: " + component); + } + } + + private static List allIndexSpecs() { + List indexSpecs = new ArrayList<>(24); + char[] components = new char[] { 's', 'p', 'o', 'c' }; + permute(components, 0, indexSpecs); + return indexSpecs; + } + + private static void permute(char[] components, int index, List indexSpecs) { + if (index == components.length) { + indexSpecs.add(new String(components)); + return; + } + + for (int i = index; i < components.length; i++) { + swap(components, index, i); + permute(components, index + 1, indexSpecs); + swap(components, index, i); + } + } + + private static void swap(char[] values, int left, int right) { + char current = values[left]; + values[left] = values[right]; + values[right] = current; + } + + private static void forEachConfiguredIndexCombination(List allIndexSpecs, + Consumer> consumer) { + int maxSize = Math.min(MAX_CONFIGURED_INDEXES, allIndexSpecs.size()); + List current = new ArrayList<>(maxSize); + for (int combinationSize = 1; combinationSize <= maxSize; combinationSize++) { + buildCombinations(allIndexSpecs, 0, combinationSize, current, consumer); + } + } + + private static void buildCombinations(List values, int start, int remaining, List current, + Consumer> consumer) { + if (remaining == 0) { + consumer.accept(new ArrayList<>(current)); + return; + } + + for (int i = start; i <= values.size() - remaining; i++) { + current.add(values.get(i)); + buildCombinations(values, i + 1, remaining - 1, current, consumer); + current.remove(current.size() - 1); + } + } + + private static long expectedCombinationCount(int totalIndexes, int maxConfiguredIndexes) { + int maxSize = Math.min(totalIndexes, maxConfiguredIndexes); + long count = 0; + for (int size = 1; size <= maxSize; size++) { + count += nChooseK(totalIndexes, size); + } + return count; + } + + private static long nChooseK(int n, int k) { + long value = 1; + for (int i = 1; i <= k; i++) { + value = value * (n - k + i) / i; + } + return value; + } + + @SuppressWarnings("unchecked") + private static List indexes(TripleStore tripleStore) throws Exception { + Field field = TripleStore.class.getDeclaredField("indexes"); + field.setAccessible(true); + return (List) field.get(tripleStore); + } +} From f24bc0ca7351f02a48630832e0e0f49825ae1247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ha=CC=8Avard=20Ottestad?= Date: Sat, 28 Feb 2026 16:39:19 +0100 Subject: [PATCH 2/3] style: GH-0000 format lmdb index selection combinatorics test --- .../sail/lmdb/TripleStoreIndexSelectionCombinatoricsTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreIndexSelectionCombinatoricsTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreIndexSelectionCombinatoricsTest.java index 10ab5b2a0b..43f86edb2b 100644 --- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreIndexSelectionCombinatoricsTest.java +++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreIndexSelectionCombinatoricsTest.java @@ -60,7 +60,8 @@ public void selectsMaxScoreIndexForEveryPatternAndConfiguredIndexCombination(@Te } for (long[] pattern : statementPatterns) { - TripleStore.TripleIndex selected = selectorStore.getBestIndex(pattern[0], pattern[1], pattern[2], + TripleStore.TripleIndex selected = selectorStore.getBestIndex(pattern[0], pattern[1], + pattern[2], pattern[3]); String selectedSpec = new String(selected.getFieldSeq()); int selectedScore = patternScore(selectedSpec, pattern); From bc2a3a5981b8709c209382d6849ef6198dc84ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ha=CC=8Avard=20Ottestad?= Date: Sat, 28 Feb 2026 17:42:29 +0100 Subject: [PATCH 3/3] fix: GH-0000 exit on SIGTERM before context attach --- .../serverboot/SignalShutdownHandler.java | 11 ++- .../tools/serverboot/ServerBootSignalIT.java | 68 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/tools/server-boot/src/main/java/org/eclipse/rdf4j/tools/serverboot/SignalShutdownHandler.java b/tools/server-boot/src/main/java/org/eclipse/rdf4j/tools/serverboot/SignalShutdownHandler.java index 709803d786..11741c4bb7 100644 --- a/tools/server-boot/src/main/java/org/eclipse/rdf4j/tools/serverboot/SignalShutdownHandler.java +++ b/tools/server-boot/src/main/java/org/eclipse/rdf4j/tools/serverboot/SignalShutdownHandler.java @@ -80,17 +80,26 @@ private void handleSignal(String signalName) { context.close(); } logger.info("Application context closed after SIG{}, exit status {}", signalName, exitCode); - System.exit(exitCode); + exitJvm(exitCode, signalName); } catch (Throwable e) { logger.warn("Error while shutting down after SIG{}", signalName, e); } } else { logger.warn("SIG{} received before application context became available; shutting down immediately.", signalName); + exitJvm(0, signalName); } } + private static void exitJvm(int exitCode, String signalName) { + try { + System.exit(exitCode); + } catch (SecurityException e) { + logger.error("System.exit({}) blocked by security manager after SIG{}", exitCode, signalName, e); + } + } + private static void startDelayedSystemExitThread(String signalName) { // Start a thread that will forcibly exit the JVM after a delay, in case spring-boot hangs during shutdown Thread thread = new Thread(() -> { diff --git a/tools/server-boot/src/test/java/org/eclipse/rdf4j/tools/serverboot/ServerBootSignalIT.java b/tools/server-boot/src/test/java/org/eclipse/rdf4j/tools/serverboot/ServerBootSignalIT.java index 249dc800e1..8e47c093bb 100644 --- a/tools/server-boot/src/test/java/org/eclipse/rdf4j/tools/serverboot/ServerBootSignalIT.java +++ b/tools/server-boot/src/test/java/org/eclipse/rdf4j/tools/serverboot/ServerBootSignalIT.java @@ -29,6 +29,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import org.eclipse.rdf4j.model.IRI; @@ -95,6 +96,11 @@ void gracefullyStopsOnSigterm() throws Exception { assertGracefulShutdown("TERM"); } + @Test + void exitsOnSigtermBeforeContextAttachment() throws Exception { + assertShutdownBeforeContextAttachment("TERM"); + } + private void assertGracefulShutdownWithSigintFallback() throws Exception { assertGracefulShutdown("INT", true); } @@ -103,6 +109,52 @@ private void assertGracefulShutdown(String signalName) throws Exception { assertGracefulShutdown(signalName, false); } + private void assertShutdownBeforeContextAttachment(String signalName) throws Exception { + Path projectRoot = Path.of("").toAbsolutePath(); + String javaBin = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + int serverPort = findFreePort(); + int managementPort = findFreePort(); + + Path targetDir = projectRoot.resolve("target"); + Path jarPath = Files.list(targetDir) + .sorted(Comparator.comparing(Path::toString)) + .filter(p -> p.toString().endsWith(".jar")) + .filter(p -> !p.toString().endsWith("-sources.jar")) + .filter(p -> !p.toString().endsWith("-javadoc.jar")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Could not find executable JAR in " + targetDir)); + + ProcessBuilder processBuilder = new ProcessBuilder(javaBin, "-jar", jarPath.toString(), + "--server.port=" + serverPort, + "--management.server.port=" + managementPort); + processBuilder.directory(projectRoot.toFile()); + processBuilder.redirectErrorStream(true); + + Process process = processBuilder.start(); + cleanupActions.add(() -> process.destroyForcibly()); + + CountDownLatch started = new CountDownLatch(1); + StringBuilder outputBuffer = new StringBuilder(); + startStreamGobbler(process, started, outputBuffer); + + boolean registeredHandler = waitForOutputContains(outputBuffer, + "Registered SIGTERM handler for graceful shutdown.", 30, SECONDS); + assertThat(registeredHandler) + .as(() -> "Did not observe SIGTERM handler registration before sending signal. Output:\\n" + + outputBuffer) + .isTrue(); + + sendSignal(process.pid(), signalName); + boolean exited = process.waitFor(30, SECONDS); + assertThat(exited) + .as(() -> "Process did not exit after SIG" + signalName + + " sent before context attachment. Output:\\n" + outputBuffer) + .isTrue(); + assertThat(process.exitValue()) + .as(() -> "Process exit value after startup-phase SIG" + signalName + ". Output:\\n" + outputBuffer) + .isEqualTo(0); + } + private void assertGracefulShutdown(String signalName, boolean allowSigtermFallback) throws Exception { Path projectRoot = Path.of("").toAbsolutePath(); String javaBin = Path.of(System.getProperty("java.home"), "bin", "java").toString(); @@ -196,6 +248,22 @@ private void sendSignal(long pid, String signalName) throws IOException, Interru } } + private boolean waitForOutputContains(StringBuilder outputBuffer, String marker, long timeout, TimeUnit unit) + throws InterruptedException { + long deadline = System.nanoTime() + unit.toNanos(timeout); + while (System.nanoTime() < deadline) { + synchronized (outputBuffer) { + if (outputBuffer.indexOf(marker) >= 0) { + return true; + } + } + Thread.sleep(100); + } + synchronized (outputBuffer) { + return outputBuffer.indexOf(marker) >= 0; + } + } + private void exerciseRemoteRepository(String serverUrl, StringBuilder outputBuffer) throws InterruptedException, RepositoryException, RepositoryConfigException { RemoteRepositoryManager manager = awaitRepositoryManager(serverUrl, outputBuffer);