diff --git a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/evaluationsteps/StatementPatternQueryEvaluationStep.java b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/evaluationsteps/StatementPatternQueryEvaluationStep.java index c9e525bd17..337bfff7ad 100644 --- a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/evaluationsteps/StatementPatternQueryEvaluationStep.java +++ b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/evaluationsteps/StatementPatternQueryEvaluationStep.java @@ -276,7 +276,10 @@ private JoinStatementWithBindingSetIterator getIteration(BindingSet bindings) { } if (iteration instanceof IndexReportingIterator) { - statementPattern.setIndexName(((IndexReportingIterator) iteration).getIndexName()); + statementPattern.setIndexNameSupplier( + ((IndexReportingIterator) iteration)::getIndexName); + } else { + statementPattern.setIndexName(null); } if (iteration instanceof EmptyIteration) { @@ -329,7 +332,10 @@ private ConvertStatementToBindingSetIterator getIteration() { iteration = tripleSource.getStatements((Resource) subject, (IRI) predicate, object, contexts); } if (iteration instanceof IndexReportingIterator) { - statementPattern.setIndexName(((IndexReportingIterator) iteration).getIndexName()); + statementPattern.setIndexNameSupplier( + ((IndexReportingIterator) iteration)::getIndexName); + } else { + statementPattern.setIndexName(null); } if (iteration instanceof EmptyIteration) { diff --git a/core/queryalgebra/model/src/main/java/org/eclipse/rdf4j/query/algebra/StatementPattern.java b/core/queryalgebra/model/src/main/java/org/eclipse/rdf4j/query/algebra/StatementPattern.java index a2c7392059..03666db2d8 100644 --- a/core/queryalgebra/model/src/main/java/org/eclipse/rdf4j/query/algebra/StatementPattern.java +++ b/core/queryalgebra/model/src/main/java/org/eclipse/rdf4j/query/algebra/StatementPattern.java @@ -18,6 +18,7 @@ import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.eclipse.rdf4j.common.annotation.Experimental; @@ -65,6 +66,7 @@ public enum Scope { private StatementOrder statementOrder; private String indexName; + private transient Supplier indexNameSupplier; private Set assuredBindingNames; private List varList; @@ -537,11 +539,22 @@ public Var getOrder() { @Experimental public String getIndexName() { + if (indexNameSupplier != null) { + indexName = indexNameSupplier.get(); + indexNameSupplier = null; + } return indexName; } @Experimental public void setIndexName(String indexName) { this.indexName = indexName; + this.indexNameSupplier = null; + } + + @Experimental + public void setIndexNameSupplier(Supplier indexNameSupplier) { + this.indexNameSupplier = indexNameSupplier; + this.indexName = null; } } diff --git a/core/queryalgebra/model/src/main/java/org/eclipse/rdf4j/query/algebra/helpers/QueryModelTreeToGenericPlanNode.java b/core/queryalgebra/model/src/main/java/org/eclipse/rdf4j/query/algebra/helpers/QueryModelTreeToGenericPlanNode.java index bd5ea5cddc..3b63a3dc6e 100644 --- a/core/queryalgebra/model/src/main/java/org/eclipse/rdf4j/query/algebra/helpers/QueryModelTreeToGenericPlanNode.java +++ b/core/queryalgebra/model/src/main/java/org/eclipse/rdf4j/query/algebra/helpers/QueryModelTreeToGenericPlanNode.java @@ -17,6 +17,7 @@ import org.eclipse.rdf4j.query.algebra.BinaryTupleOperator; import org.eclipse.rdf4j.query.algebra.QueryModelNode; import org.eclipse.rdf4j.query.algebra.QueryRoot; +import org.eclipse.rdf4j.query.algebra.StatementPattern; import org.eclipse.rdf4j.query.algebra.VariableScopeChange; import org.eclipse.rdf4j.query.explanation.GenericPlanNode; @@ -44,6 +45,9 @@ public GenericPlanNode getGenericPlanNode() { @Override protected void meetNode(QueryModelNode node) { + if (node instanceof StatementPattern) { + ((StatementPattern) node).getIndexName(); + } GenericPlanNode genericPlanNode = new GenericPlanNode(node.getSignature()); genericPlanNode.setCostEstimate(node.getCostEstimate()); genericPlanNode.setResultSizeEstimate(node.getResultSizeEstimate()); diff --git a/core/sail/base/src/main/java/org/eclipse/rdf4j/sail/TripleSourceIterationWrapper.java b/core/sail/base/src/main/java/org/eclipse/rdf4j/sail/TripleSourceIterationWrapper.java index b588a05504..342562cfc4 100644 --- a/core/sail/base/src/main/java/org/eclipse/rdf4j/sail/TripleSourceIterationWrapper.java +++ b/core/sail/base/src/main/java/org/eclipse/rdf4j/sail/TripleSourceIterationWrapper.java @@ -16,10 +16,11 @@ import org.eclipse.rdf4j.common.annotation.InternalUseOnly; import org.eclipse.rdf4j.common.iteration.CloseableIteration; +import org.eclipse.rdf4j.common.iteration.IndexReportingIterator; import org.eclipse.rdf4j.query.QueryEvaluationException; @InternalUseOnly -public class TripleSourceIterationWrapper implements CloseableIteration { +public class TripleSourceIterationWrapper implements CloseableIteration, IndexReportingIterator { private final CloseableIteration delegate; private boolean closed = false; @@ -28,6 +29,14 @@ public TripleSourceIterationWrapper(CloseableIteration delegate) { this.delegate = Objects.requireNonNull(delegate, "The iterator was null"); } + @Override + public String getIndexName() { + if (delegate instanceof IndexReportingIterator) { + return ((IndexReportingIterator) delegate).getIndexName(); + } + return null; + } + /** * Checks whether the underlying iteration contains more elements. * diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbRecordIterator.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbRecordIterator.java index 68c5352a73..95daaf4ab0 100644 --- a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbRecordIterator.java +++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbRecordIterator.java @@ -24,6 +24,14 @@ import java.io.IOException; import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.LongAdder; import org.eclipse.rdf4j.common.concurrent.locks.StampedLongAdderLockManager; import org.eclipse.rdf4j.sail.SailException; @@ -41,6 +49,12 @@ */ class LmdbRecordIterator implements RecordIterator { private static final Logger log = LoggerFactory.getLogger(LmdbRecordIterator.class); + private static final List COMPONENT_ORDER = List.of('s', 'p', 'o', 'c'); + private static final ConcurrentMap RECOMMENDED_INDEX_TRACKER = new ConcurrentHashMap<>(); + private static final ConcurrentMap INDEX_NAME_CACHE = new ConcurrentHashMap<>(); + private static final Set PENDING_RECOMMENDATIONS = ConcurrentHashMap.newKeySet(); + private static final Set RECORDED_RECOMMENDATIONS = ConcurrentHashMap.newKeySet(); + private final Pool pool; private final TripleIndex index; @@ -139,6 +153,343 @@ class LmdbRecordIterator implements RecordIterator { } } + static ConcurrentMap getRecommendedIndexTracker() { + return RECOMMENDED_INDEX_TRACKER; + } + + static void resetIndexRecommendationTracker() { + RECOMMENDED_INDEX_TRACKER.clear(); + INDEX_NAME_CACHE.clear(); + PENDING_RECOMMENDATIONS.clear(); + RECORDED_RECOMMENDATIONS.clear(); + } + + private static String computeIndexName(TripleIndex index, long subj, long pred, long obj, long context, + boolean recordUsage) { + String actual = new String(index.getFieldSeq()); + int boundCount = countBound(subj, pred, obj, context); + if (boundCount <= 0) { + return actual; + } + + int score = index.getPatternScore(subj, pred, obj, context); + if (score >= boundCount) { + return actual; + } + + CandidateIndex recommendation = selectRecommendedIndex(actual, subj, pred, obj, context, recordUsage); + if (recommendation == null) { + return actual; + } + + return actual + " (scan; consider " + recommendation.name + ")"; + } + + private static int countBound(long subj, long pred, long obj, long context) { + int count = 0; + if (subj >= 0) { + count++; + } + if (pred >= 0) { + count++; + } + if (obj >= 0) { + count++; + } + if (context >= 0) { + count++; + } + return count; + } + + private static CandidateIndex selectRecommendedIndex(String actual, long subj, long pred, long obj, + long context, boolean recordUsage) { + List candidates = buildCandidateIndexes(subj, pred, obj, context, recordUsage); + if (candidates.isEmpty()) { + return null; + } + + CandidateIndex best = null; + for (CandidateIndex candidate : candidates) { + if (candidate.name.equals(actual)) { + continue; + } + + if (best == null || candidate.count > best.count + || (candidate.count == best.count && candidate.patternScore > best.patternScore) + || (candidate.count == best.count && candidate.patternScore == best.patternScore + && candidate.orderDeviation < best.orderDeviation) + || (candidate.count == best.count && candidate.patternScore == best.patternScore + && candidate.orderDeviation == best.orderDeviation + && candidate.name.compareTo(best.name) < 0)) { + best = candidate; + } + } + + if (best == null) { + best = candidates.get(0); + } + + if (recordUsage) { + for (CandidateIndex candidate : candidates) { + if (candidate.counter != null) { + candidate.counter.increment(); + } + } + } + + return best; + } + + private static List buildCandidateIndexes(long subj, long pred, long obj, long context, + boolean recordUsage) { + List boundComponents = gatherBoundComponents(subj, pred, obj, context); + if (boundComponents.isEmpty()) { + return Collections.emptyList(); + } + + List preferredOrder = determinePreferredOrder(subj, pred, obj, context, boundComponents); + + List boundPermutations = permuteBoundComponents(boundComponents); + List unboundPermutations = permuteUnboundComponents(boundComponents); + + if (unboundPermutations.isEmpty()) { + unboundPermutations = Collections.singletonList(""); + } + + List result = new ArrayList<>(boundPermutations.size() * unboundPermutations.size()); + + for (String bound : boundPermutations) { + for (String suffix : unboundPermutations) { + String candidate = bound + suffix; + addCandidate(result, candidate, preferredOrder, subj, pred, obj, context, recordUsage); + } + } + + return result; + } + + private static void addCandidate(Collection candidates, String candidate, + List preferredOrder, long subj, long pred, long obj, long context, boolean recordUsage) { + LongAdder counter = RECOMMENDED_INDEX_TRACKER.get(candidate); + if (counter == null && recordUsage) { + counter = RECOMMENDED_INDEX_TRACKER.computeIfAbsent(candidate, key -> new LongAdder()); + } + long count = counter != null ? counter.sum() : 0L; + int score = computePatternScore(candidate, subj, pred, obj, context); + int deviation = computeDeviation(candidate, preferredOrder); + candidates.add(new CandidateIndex(candidate, count, score, deviation, counter)); + } + + private static boolean shouldRecordUsage() { + StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); + for (StackTraceElement element : stackTrace) { + String className = element.getClassName(); + String methodName = element.getMethodName(); + if (("org.eclipse.rdf4j.query.algebra.StatementPattern".equals(className) + && "getIndexName".equals(methodName)) + || ("org.eclipse.rdf4j.repository.sail.SailQuery".equals(className) && "explain".equals(methodName)) + || ("org.eclipse.rdf4j.sail.base.SailSourceConnection".equals(className) + && "explain".equals(methodName)) + || "org.eclipse.rdf4j.query.algebra.helpers.QueryModelTreeToGenericPlanNode".equals(className) + || ("org.eclipse.rdf4j.query.explanation.Explanation".equals(className) + && "toString".equals(methodName))) { + return true; + } + } + return false; + } + + private static List gatherBoundComponents(long subj, long pred, long obj, long context) { + List bound = new ArrayList<>(4); + if (subj >= 0) { + bound.add('s'); + } + if (pred >= 0) { + bound.add('p'); + } + if (obj >= 0) { + bound.add('o'); + } + if (context >= 0) { + bound.add('c'); + } + return bound; + } + + private static List determinePreferredOrder(long subj, long pred, long obj, long context, + List boundComponents) { + List order = new ArrayList<>(COMPONENT_ORDER.size()); + + if (subj >= 0) { + order.add('s'); + } + + boolean subjectBound = subj >= 0; + boolean contextBound = context >= 0; + boolean predicateBound = pred >= 0; + boolean objectBound = obj >= 0; + + if (contextBound && !subjectBound) { + if (objectBound) { + order.add('o'); + } + if (predicateBound) { + order.add('p'); + } + if (contextBound) { + order.add('c'); + } + } else { + if (predicateBound) { + order.add('p'); + } + if (objectBound) { + order.add('o'); + } + if (contextBound) { + order.add('c'); + } + } + + for (Character component : boundComponents) { + if (!order.contains(component)) { + order.add(component); + } + } + + for (Character component : COMPONENT_ORDER) { + if (!order.contains(component)) { + order.add(component); + } + } + + return order; + } + + private static List permuteBoundComponents(List boundComponents) { + char[] items = toCharArray(boundComponents); + boolean[] used = new boolean[items.length]; + StringBuilder current = new StringBuilder(items.length); + List result = new ArrayList<>(); + permuteCharacters(items, used, current, result); + return result; + } + + private static List permuteUnboundComponents(List boundComponents) { + List unbound = new ArrayList<>(COMPONENT_ORDER); + for (Character component : boundComponents) { + unbound.remove(component); + } + + if (unbound.isEmpty()) { + return Collections.emptyList(); + } + + char[] items = toCharArray(unbound); + boolean[] used = new boolean[items.length]; + StringBuilder current = new StringBuilder(items.length); + List permutations = new ArrayList<>(); + permuteCharacters(items, used, current, permutations); + return permutations; + } + + private static void permuteCharacters(char[] items, boolean[] used, StringBuilder current, + List result) { + if (current.length() == items.length) { + result.add(current.toString()); + return; + } + + for (int i = 0; i < items.length; i++) { + if (!used[i]) { + used[i] = true; + current.append(items[i]); + permuteCharacters(items, used, current, result); + current.deleteCharAt(current.length() - 1); + used[i] = false; + } + } + } + + private static int computeDeviation(String sequence, List preferredOrder) { + int deviation = 0; + for (int i = 0; i < sequence.length(); i++) { + char component = sequence.charAt(i); + int preferred = preferredOrder.indexOf(component); + if (preferred < 0) { + preferred = preferredOrder.size(); + } + deviation += Math.abs(preferred - i); + } + return deviation; + } + + private static int computePatternScore(String indexName, long subj, long pred, long obj, long context) { + int score = 0; + for (int i = 0; i < indexName.length(); i++) { + char component = indexName.charAt(i); + switch (component) { + case 's': + if (subj >= 0) { + score++; + } else { + return score; + } + break; + case 'p': + if (pred >= 0) { + score++; + } else { + return score; + } + break; + case 'o': + if (obj >= 0) { + score++; + } else { + return score; + } + break; + case 'c': + if (context >= 0) { + score++; + } else { + return score; + } + break; + default: + throw new IllegalArgumentException("invalid component '" + component + "' in index: " + + indexName); + } + } + return score; + } + + private static char[] toCharArray(List components) { + char[] items = new char[components.size()]; + for (int i = 0; i < components.size(); i++) { + items[i] = components.get(i); + } + return items; + } + + private static final class CandidateIndex { + final String name; + final long count; + final int patternScore; + final int orderDeviation; + final LongAdder counter; + + CandidateIndex(String name, long count, int patternScore, int orderDeviation, LongAdder counter) { + this.name = name; + this.count = count; + this.patternScore = patternScore; + this.orderDeviation = orderDeviation; + this.counter = counter; + } + } + @Override public long[] next() { long readStamp; @@ -147,6 +498,7 @@ public long[] next() { } catch (InterruptedException e) { throw new SailException(e); } + try { if (closed) { log.debug("Calling next() on an LmdbRecordIterator that is already closed, returning null"); @@ -216,6 +568,38 @@ public long[] next() { } } + @Override + public String getIndexName() { + while (true) { + boolean explanationContext = shouldRecordUsage(); + String cached = INDEX_NAME_CACHE.get(this); + if (cached != null) { + if (explanationContext && RECORDED_RECOMMENDATIONS.add(this)) { + String computed = computeIndexName(index, subj, pred, obj, context, true); + INDEX_NAME_CACHE.put(this, computed); + return computed; + } + return cached; + } + + if (PENDING_RECOMMENDATIONS.add(this)) { + try { + boolean recordUsage = explanationContext; + String computed = computeIndexName(index, subj, pred, obj, context, recordUsage); + INDEX_NAME_CACHE.put(this, computed); + if (recordUsage) { + RECORDED_RECOMMENDATIONS.add(this); + } + return computed; + } finally { + PENDING_RECOMMENDATIONS.remove(this); + } + } + + Thread.onSpinWait(); + } + } + private boolean matches() { if (groupMatcher != null) { @@ -230,6 +614,9 @@ private boolean matches() { private void closeInternal(boolean maybeCalledAsync) { if (!closed) { + INDEX_NAME_CACHE.remove(this); + PENDING_RECOMMENDATIONS.remove(this); + RECORDED_RECOMMENDATIONS.remove(this); long writeStamp = 0L; boolean writeLocked = false; if (maybeCalledAsync && ownerThread != Thread.currentThread()) { diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbStatementIterator.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbStatementIterator.java index e4b6429afa..652aeeed7d 100644 --- a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbStatementIterator.java +++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbStatementIterator.java @@ -14,6 +14,7 @@ import java.util.NoSuchElementException; import org.eclipse.rdf4j.common.iteration.AbstractCloseableIteration; +import org.eclipse.rdf4j.common.iteration.IndexReportingIterator; import org.eclipse.rdf4j.model.IRI; import org.eclipse.rdf4j.model.Resource; import org.eclipse.rdf4j.model.Statement; @@ -24,7 +25,7 @@ * A statement iterator that wraps a RecordIterator containing statement records and translates these records to * {@link Statement} objects. */ -class LmdbStatementIterator extends AbstractCloseableIteration { +class LmdbStatementIterator extends AbstractCloseableIteration implements IndexReportingIterator { /*-----------* * Variables * @@ -135,4 +136,9 @@ private Statement lookAhead() { public void remove() { throw new UnsupportedOperationException(); } + + @Override + public String getIndexName() { + return recordIt.getIndexName(); + } } diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/RecordIterator.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/RecordIterator.java index c0a89c0628..447597fba4 100644 --- a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/RecordIterator.java +++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/RecordIterator.java @@ -31,4 +31,8 @@ interface RecordIterator extends Closeable { */ @Override void close(); + + default String getIndexName() { + return null; + } } diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/LmdbExplainIndexRecommendationTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/LmdbExplainIndexRecommendationTest.java new file mode 100644 index 0000000000..8ebfdacd84 --- /dev/null +++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/LmdbExplainIndexRecommendationTest.java @@ -0,0 +1,214 @@ +/******************************************************************************* + * Copyright (c) 2025 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 + *******************************************************************************/ +package org.eclipse.rdf4j.sail.lmdb; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.LongAdder; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.eclipse.rdf4j.model.IRI; +import org.eclipse.rdf4j.model.vocabulary.FOAF; +import org.eclipse.rdf4j.model.vocabulary.RDF; +import org.eclipse.rdf4j.query.explanation.Explanation; +import org.eclipse.rdf4j.repository.RepositoryException; +import org.eclipse.rdf4j.repository.sail.SailRepository; +import org.eclipse.rdf4j.repository.sail.SailRepositoryConnection; +import org.eclipse.rdf4j.sail.lmdb.LmdbStore; +import org.eclipse.rdf4j.sail.lmdb.config.LmdbStoreConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class LmdbExplainIndexRecommendationTest { + + private static final String PERSON_QUERY = "PREFIX foaf: <" + FOAF.NAMESPACE + ">\n" + + "SELECT ?person WHERE { ?person a foaf:Person . }"; + + private static final String NAMED_GRAPH_QUERY = "PREFIX foaf: <" + FOAF.NAMESPACE + ">\n" + + "PREFIX ex: \n" + + "SELECT ?country WHERE { GRAPH ex:namedGraph { ?country a ex:Country . } }"; + + @TempDir + File dataDir; + + @BeforeEach + void resetRecommendations() { + LmdbRecordIterator.resetIndexRecommendationTracker(); + } + + @AfterEach + void cleanup() { + LmdbRecordIterator.resetIndexRecommendationTracker(); + } + + @ParameterizedTest + @MethodSource("allTripleIndexPermutations") + void explainPlanCoversAllIndexPermutations(String index, boolean directLookup) { + String plan = runExplainQuery(index, PERSON_QUERY, connection -> connection.add( + connection.getValueFactory().createIRI("http://example.com/alice"), RDF.TYPE, FOAF.PERSON)); + + if (directLookup) { + assertThat(plan).contains("[index: " + index + "]"); + assertThat(plan).doesNotContain("(scan; consider"); + } else { + assertThat(plan).contains("[index: " + index + " (scan; consider posc)]"); + } + } + + @Test + void tracksAllCandidateIndexesWhenRecommending() { + String plan = runExplainQuery("psoc", PERSON_QUERY, connection -> connection.add( + connection.getValueFactory().createIRI("http://example.com/alice"), RDF.TYPE, FOAF.PERSON)); + + assertThat(plan).contains("[index: psoc (scan; consider posc)]"); + + ConcurrentMap tracked = LmdbRecordIterator.getRecommendedIndexTracker(); + assertThat(tracked.keySet()).containsExactlyInAnyOrder("posc", "pocs", "opsc", "opcs"); + assertThat(tracked.values()).allSatisfy(adder -> assertThat(adder.sum()).isEqualTo(1)); + } + + @Test + void recommendationPrefersIndexesWithHigherDemand() { + runExplainQuery("psoc", PERSON_QUERY, connection -> connection.add( + connection.getValueFactory().createIRI("http://example.com/alice"), RDF.TYPE, FOAF.PERSON)); + + String plan = runExplainQuery("cspo", NAMED_GRAPH_QUERY, connection -> { + IRI namedGraph = connection.getValueFactory().createIRI("http://example.com/namedGraph"); + connection.add(connection.getValueFactory().createIRI("http://example.com/country"), RDF.TYPE, + connection.getValueFactory().createIRI("http://example.com/Country"), namedGraph); + }); + + assertThat(plan).contains("(scan; consider opcs)"); + } + + @Test + void trackerRemainsEmptyUntilIndexNameRequested() { + runSelectQuery("psoc", PERSON_QUERY, connection -> connection.add( + connection.getValueFactory().createIRI("http://example.com/alice"), RDF.TYPE, FOAF.PERSON)); + + ConcurrentMap tracked = LmdbRecordIterator.getRecommendedIndexTracker(); + assertThat(tracked).isEmpty(); + + String plan = runExplainQuery("psoc", PERSON_QUERY, connection -> { + connection.add(connection.getValueFactory().createIRI("http://example.com/bob"), RDF.TYPE, FOAF.PERSON); + }); + + assertThat(plan).contains("(scan; consider posc)"); + assertThat(tracked).isNotEmpty(); + } + + @Test + void explanationStringOnlyCountsCandidatesOnce() { + File storeDir = new File(dataDir, "psoc-repeat-" + System.nanoTime()); + storeDir.mkdirs(); + + SailRepository repository = new SailRepository(new LmdbStore(storeDir, new LmdbStoreConfig("psoc"))); + repository.init(); + + try (SailRepositoryConnection connection = repository.getConnection()) { + connection.add(connection.getValueFactory().createIRI("http://example.com/alice"), RDF.TYPE, FOAF.PERSON); + Explanation explanation = connection.prepareTupleQuery(PERSON_QUERY).explain(Explanation.Level.Optimized); + + explanation.toString(); + + ConcurrentMap tracked = LmdbRecordIterator.getRecommendedIndexTracker(); + assertThat(tracked).isNotEmpty(); + Map before = tracked.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().sum())); + + explanation.toString(); + + tracked.forEach((name, counter) -> assertThat(counter.sum()).isEqualTo(before.get(name))); + } catch (RepositoryException e) { + throw new RuntimeException(e); + } finally { + repository.shutDown(); + } + } + + private static Stream allTripleIndexPermutations() { + List permutations = new ArrayList<>(); + permute("spoc".toCharArray(), new boolean[4], new StringBuilder(), permutations); + Set directIndexes = Set.of("posc", "pocs", "opsc", "opcs"); + return permutations.stream().map(index -> Arguments.of(index, directIndexes.contains(index))); + } + + private static void permute(char[] chars, boolean[] used, StringBuilder current, List result) { + if (current.length() == chars.length) { + result.add(current.toString()); + return; + } + + for (int i = 0; i < chars.length; i++) { + if (!used[i]) { + used[i] = true; + current.append(chars[i]); + permute(chars, used, current, result); + current.deleteCharAt(current.length() - 1); + used[i] = false; + } + } + } + + private String runExplainQuery(String index, String query, RepositoryConnectionConsumer consumer) { + File storeDir = new File(dataDir, index + "-" + query.hashCode() + "-" + System.nanoTime()); + storeDir.mkdirs(); + + SailRepository repository = new SailRepository(new LmdbStore(storeDir, new LmdbStoreConfig(index))); + repository.init(); + + try (SailRepositoryConnection connection = repository.getConnection()) { + consumer.accept(connection); + Explanation explanation = connection.prepareTupleQuery(query).explain(Explanation.Level.Optimized); + return explanation.toString(); + } catch (RepositoryException e) { + throw new RuntimeException(e); + } finally { + repository.shutDown(); + } + } + + private void runSelectQuery(String index, String query, RepositoryConnectionConsumer consumer) { + File storeDir = new File(dataDir, index + "-select-" + query.hashCode() + "-" + System.nanoTime()); + storeDir.mkdirs(); + + SailRepository repository = new SailRepository(new LmdbStore(storeDir, new LmdbStoreConfig(index))); + repository.init(); + + try (SailRepositoryConnection connection = repository.getConnection()) { + consumer.accept(connection); + connection.prepareTupleQuery(query).evaluate().close(); + } catch (RepositoryException e) { + throw new RuntimeException(e); + } finally { + repository.shutDown(); + } + } + + @FunctionalInterface + private interface RepositoryConnectionConsumer { + void accept(SailRepositoryConnection connection) throws RepositoryException; + } + +}