diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/PickRightTiebreaker.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/PickRightTiebreaker.java
index 25e7b683f77..0015d5853d2 100644
--- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/PickRightTiebreaker.java
+++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/PickRightTiebreaker.java
@@ -42,7 +42,7 @@
* in plan changes. See: Issue #3998.
*
*/
-final class PickRightTiebreaker implements Tiebreaker {
+public final class PickRightTiebreaker implements Tiebreaker {
private static final PickRightTiebreaker INSTANCE = new PickRightTiebreaker();
private PickRightTiebreaker() {
@@ -57,7 +57,7 @@ public int compare(@Nonnull final RecordQueryPlannerConfiguration configuration,
}
@Nonnull
- static PickRightTiebreaker pickRightTiebreaker() {
+ public static PickRightTiebreaker pickRightTiebreaker() {
return INSTANCE;
}
}
diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/PlanningCostModel.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/PlanningCostModel.java
index b9f81270ea1..3d369b244ac 100644
--- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/PlanningCostModel.java
+++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/PlanningCostModel.java
@@ -336,27 +336,27 @@ static SmallestCardinalityOfDataAccessesTiebreaker smallestCardinalityOfDataAcce
}
@Nonnull
- static LowestNumUnsatisfiedFiltersTiebreaker lowestNumUnsatisfiedFiltersTiebreaker() {
+ public static Tiebreaker lowestNumUnsatisfiedFiltersTiebreaker() {
return LowestNumUnsatisfiedFiltersTiebreaker.INSTANCE;
}
@Nonnull
- static LowestNumDataAccessesTiebreaker lowestNumDataAccessesTiebreaker() {
+ public static Tiebreaker lowestNumDataAccessesTiebreaker() {
return LowestNumDataAccessesTiebreaker.INSTANCE;
}
@Nonnull
- static RecursiveCteOperatorTiebreaker recursiveCteOperatorTiebreaker() {
+ public static Tiebreaker recursiveCteOperatorTiebreaker() {
return RecursiveCteOperatorTiebreaker.INSTANCE;
}
@Nonnull
- static InOperatorTiebreaker inOperatorTiebreaker() {
+ public static Tiebreaker inOperatorTiebreaker() {
return InOperatorTiebreaker.INSTANCE;
}
@Nonnull
- static PrimaryScanVsIndexScanTiebreaker primaryScanVsIndexScanTiebreaker() {
+ public static Tiebreaker primaryScanVsIndexScanTiebreaker() {
return PrimaryScanVsIndexScanTiebreaker.INSTANCE;
}
@@ -366,22 +366,22 @@ static VectorIndexEnginePreferenceTiebreaker vectorIndexEnginePreferenceTiebreak
}
@Nonnull
- static LowestTypeFilterCountTiebreaker lowestTypeFilterCountTiebreaker() {
+ public static Tiebreaker lowestTypeFilterCountTiebreaker() {
return LowestTypeFilterCountTiebreaker.INSTANCE;
}
@Nonnull
- static DeepestTypeFilterPositionTiebreaker deepestTypeFilterPositionTiebreaker() {
+ public static Tiebreaker deepestTypeFilterPositionTiebreaker() {
return DeepestTypeFilterPositionTiebreaker.INSTANCE;
}
@Nonnull
- static BetterIndexScanTiebreaker betterIndexScanTiebreaker() {
+ public static Tiebreaker betterIndexScanTiebreaker() {
return BetterIndexScanTiebreaker.INSTANCE;
}
@Nonnull
- static DeepestDistinctTiebreaker deepestDistinctTiebreaker() {
+ public static Tiebreaker deepestDistinctTiebreaker() {
return DeepestDistinctTiebreaker.INSTANCE;
}
@@ -391,12 +391,12 @@ static LowestNumUnmatchedFieldsTiebreaker lowestNumUnmatchedFieldsTiebreaker() {
}
@Nonnull
- static HighestNumInJoinTiebreaker highestNumInJoinTiebreaker() {
+ public static Tiebreaker highestNumInJoinTiebreaker() {
return HighestNumInJoinTiebreaker.INSTANCE;
}
@Nonnull
- static LowestNumSimpleOperationsTiebreaker lowestNumSimpleOperationsTiebreaker() {
+ public static Tiebreaker lowestNumSimpleOperationsTiebreaker() {
return LowestNumSimpleOperationsTiebreaker.INSTANCE;
}
@@ -406,12 +406,12 @@ static LowestOuterCardinalityTiebreaker lowestOuterCardinalityTiebreaker() {
}
@Nonnull
- static LowestNumDefaultOnEmptyTiebreaker lowestNumDefaultOnEmptyTiebreaker() {
+ public static Tiebreaker lowestNumDefaultOnEmptyTiebreaker() {
return LowestNumDefaultOnEmptyTiebreaker.INSTANCE;
}
@Nonnull
- static PlanHashTiebreaker planHashTiebreaker() {
+ public static Tiebreaker planHashTiebreaker() {
return PlanHashTiebreaker.INSTANCE;
}
diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/StableSelectorCostModel.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/StableSelectorCostModel.java
deleted file mode 100644
index 2372c34de55..00000000000
--- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/StableSelectorCostModel.java
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * StableSelectorCostModel.java
- *
- * This source file is part of the FoundationDB open source project
- *
- * Copyright 2015-2025 Apple Inc. and the FoundationDB project authors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.apple.foundationdb.record.query.plan.cascades.costing;
-
-import com.apple.foundationdb.annotation.API;
-import com.apple.foundationdb.annotation.SpotBugsSuppressWarnings;
-import com.apple.foundationdb.record.query.plan.RecordQueryPlannerConfiguration;
-import com.apple.foundationdb.record.query.plan.cascades.CascadesPlanner;
-import com.apple.foundationdb.record.query.plan.cascades.expressions.RelationalExpression;
-import com.apple.foundationdb.record.query.plan.plans.RecordQueryPlan;
-import com.google.common.collect.ImmutableList;
-
-import javax.annotation.Nonnull;
-import java.util.Comparator;
-import java.util.Optional;
-import java.util.Set;
-
-/**
- * A comparator implementing a simple cost model for the {@link CascadesPlanner} to choose the plan with the smallest
- * plan hash.
- */
-@API(API.Status.EXPERIMENTAL)
-@SpotBugsSuppressWarnings("SE_COMPARATOR_SHOULD_BE_SERIALIZABLE")
-public class StableSelectorCostModel implements CascadesCostModel, Comparator {
- @Nonnull
- private static final Tiebreaker tiebreaker =
- Tiebreaker.combineTiebreakers(ImmutableList.of(
- PlanningCostModel.planHashTiebreaker(),
- PickRightTiebreaker.pickRightTiebreaker()));
-
- @Nonnull
- @Override
- public RecordQueryPlannerConfiguration getConfiguration() {
- return RecordQueryPlannerConfiguration.defaultPlannerConfiguration();
- }
-
- @Nonnull
- @Override
- public Optional getBestExpression(@Nonnull final Set extends RelationalExpression> expressions) {
- return costExpressions(expressions).getOnlyExpressionMaybe();
- }
-
- @Nonnull
- private TiebreakerResult costExpressions(@Nonnull final Set extends RelationalExpression> expressions) {
- return Tiebreaker.ofContext(getConfiguration(), expressions, RecordQueryPlan.class)
- .thenApply(tiebreaker);
- }
-
- @Override
- public int compare(@Nonnull final RecordQueryPlan a,
- @Nonnull final RecordQueryPlan b) {
- return tiebreaker.compare(getConfiguration(),
- new PlanOpsMap(a), new PlanOpsMap(b),
- a, b);
- }
-}
diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/Tiebreaker.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/Tiebreaker.java
index 22aba5f4ec6..dd455ea0914 100644
--- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/Tiebreaker.java
+++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/Tiebreaker.java
@@ -61,7 +61,7 @@
*
* @param the type of expressions being compared
*/
-interface Tiebreaker {
+public interface Tiebreaker {
/**
* Compare two expressions. Like a {@link java.util.Comparator}, this should return a
diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/TiebreakerResult.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/TiebreakerResult.java
index 09ab3ae69a5..79c1362ef3e 100644
--- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/TiebreakerResult.java
+++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/costing/TiebreakerResult.java
@@ -26,7 +26,7 @@
import java.util.Optional;
import java.util.Set;
-interface TiebreakerResult {
+public interface TiebreakerResult {
@Nonnull
TiebreakerResult thenApply(@Nonnull Tiebreaker super T> nextTiebreaker);
diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/costing/StableSelectorCostModelTests.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/costing/StableSelectorCostModelTests.java
deleted file mode 100644
index b9c54a7993c..00000000000
--- a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/costing/StableSelectorCostModelTests.java
+++ /dev/null
@@ -1,142 +0,0 @@
-/*
- * CostModelTests.java
- *
- * This source file is part of the FoundationDB open source project
- *
- * Copyright 2015-2026 Apple Inc. and the FoundationDB project authors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.apple.foundationdb.record.query.plan.cascades.costing;
-
-import com.apple.foundationdb.record.query.plan.ScanComparisons;
-import com.apple.foundationdb.record.query.plan.plans.RecordQueryPlan;
-import com.apple.foundationdb.record.query.plan.plans.RecordQueryScanPlan;
-import com.google.common.collect.ImmutableSet;
-import org.junit.jupiter.api.Test;
-
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-import java.util.Set;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Tests of the {@link StableSelectorCostModel} implementation. This class is used to distinguish between entries
- * in the plan cache, and so the main invariant it has to uphold is just that it makes a consistent choice
- * for a given set of plans.
- */
-class StableSelectorCostModelTests {
- private static final RecordQueryPlan forwardScan = new RecordQueryScanPlan(ScanComparisons.EMPTY, false);
- private static final RecordQueryPlan backwardScan = new RecordQueryScanPlan(ScanComparisons.EMPTY, true);
-
- @Nonnull
- private final StableSelectorCostModel costModel = new StableSelectorCostModel();
-
- @Test
- void emptySet() {
- assertCompareAndCostingAreEquivalent(ImmutableSet.of());
- }
-
- @Test
- void singleElementSet() {
- assertCompareAndCostingAreEquivalent(ImmutableSet.of(forwardScan));
- }
-
- @Test
- void costPlansEquivalentToCompare() {
- final ImmutableSet plans = ImmutableSet.of(
- forwardScan, backwardScan);
- assertThat(plans)
- .hasSize(2);
- assertCompareAndCostingAreEquivalent(plans);
- }
-
- @Test
- void pickLowerPlanHash() {
- final RecordQueryPlan planA = new RecordQueryPlanWithFixedPlanHash("foo", 9000);
- final RecordQueryPlan planB = new RecordQueryPlanWithFixedPlanHash("bar", 9001);
-
- assertThat(assertCompareAndCostingAreEquivalent(ImmutableSet.of(planA, planB)))
- .isSameAs(planA);
- }
-
- @Test
- void pickLeftIfTwoShareTheSamePlanHash() {
- final RecordQueryPlan planA = new RecordQueryPlanWithFixedPlanHash("foo", 9001);
- final RecordQueryPlan planB = new RecordQueryPlanWithFixedPlanHash("bar", 9001);
-
- assertThat(assertCompareAndCostingAreEquivalent(ImmutableSet.of(planA, planB)))
- .isSameAs(planA);
- }
-
- @Test
- void pickLowestPlanHash() {
- final RecordQueryPlan planA = new RecordQueryPlanWithFixedPlanHash("a", 30);
- final RecordQueryPlan planB = new RecordQueryPlanWithFixedPlanHash("b", 14);
- final RecordQueryPlan planC = new RecordQueryPlanWithFixedPlanHash("c", 18);
- final RecordQueryPlan planD = new RecordQueryPlanWithFixedPlanHash("d", 20);
- final RecordQueryPlan planE = new RecordQueryPlanWithFixedPlanHash("e", 15);
-
- assertThat(assertCompareAndCostingAreEquivalent(ImmutableSet.of(planA, planB, planC, planD, planE)))
- .isSameAs(planB);
- }
-
- @Test
- void pickLeftMostIfMultipleShareTheSamePlanHash() {
- final RecordQueryPlan planA = new RecordQueryPlanWithFixedPlanHash("a", 30);
- final RecordQueryPlan planB = new RecordQueryPlanWithFixedPlanHash("b", 14);
- final RecordQueryPlan planC = new RecordQueryPlanWithFixedPlanHash("c", 14);
- final RecordQueryPlan planD = new RecordQueryPlanWithFixedPlanHash("d", 20);
- final RecordQueryPlan planE = new RecordQueryPlanWithFixedPlanHash("e", 15);
- final RecordQueryPlan planF = new RecordQueryPlanWithFixedPlanHash("f", 14);
-
- assertThat(assertCompareAndCostingAreEquivalent(ImmutableSet.of(planA, planB, planC, planD, planE, planF)))
- .isSameAs(planB);
- }
-
- @Nullable
- private RecordQueryPlan assertCompareAndCostingAreEquivalent(@Nonnull Set plans) {
- final RecordQueryPlan fromComparison = bestViaComparison(plans);
- final RecordQueryPlan fromCosting = bestViaCosting(plans);
- if (plans.isEmpty()) {
- assertThat(fromComparison)
- .isNull();
- } else {
- assertThat(fromComparison)
- .isNotNull();
- }
- assertThat(fromCosting)
- .isSameAs(fromComparison);
- return fromComparison;
- }
-
- @Nullable
- private RecordQueryPlan bestViaComparison(@Nonnull Iterable plans) {
- RecordQueryPlan bestSoFar = null;
- for (RecordQueryPlan plan : plans) {
- // This reflects the logic in the PlanGenerator
- if (bestSoFar == null || costModel.compare(plan, bestSoFar) < 0) {
- bestSoFar = plan;
- }
- }
- return bestSoFar;
- }
-
- @Nullable
- private RecordQueryPlan bestViaCosting(@Nonnull Set plans) {
- return costModel.getBestExpression(plans)
- .orElse(null);
- }
-}
diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/costing/TiebreakerImplementationTests.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/costing/TiebreakerImplementationTests.java
index 42c9608d52c..c631a93a710 100644
--- a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/costing/TiebreakerImplementationTests.java
+++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/costing/TiebreakerImplementationTests.java
@@ -153,4 +153,17 @@ void inOperatorTest() {
assertCompares(inOperatorTiebreaker, inJoinSargedComparisonPlan, filterScanWithInPlan, 0);
assertCompares(inOperatorTiebreaker, inJoinSargedComparisonPlan, inJoinNonSargedPlan, 0); // anti-symmetry violation!
}
+
+ @Test
+ void planHashTest() {
+ final Tiebreaker planHashTiebreaker = PlanningCostModel.planHashTiebreaker();
+ final RecordQueryPlan low = new RecordQueryPlanWithFixedPlanHash("low", 14);
+ final RecordQueryPlan high = new RecordQueryPlanWithFixedPlanHash("high", 30);
+ final RecordQueryPlan alsoLow = new RecordQueryPlanWithFixedPlanHash("alsoLow", 14);
+
+ assertReflexive(planHashTiebreaker, low);
+ assertCompares(planHashTiebreaker, low, high, -1);
+ assertCompares(planHashTiebreaker, high, low, 1);
+ assertCompares(planHashTiebreaker, low, alsoLow, 0);
+ }
}
diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/PlanCacheCostModel.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/PlanCacheCostModel.java
new file mode 100644
index 00000000000..4234345d65a
--- /dev/null
+++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/PlanCacheCostModel.java
@@ -0,0 +1,94 @@
+/*
+ * PlanCacheCostModel.java
+ *
+ * This source file is part of the FoundationDB open source project
+ *
+ * Copyright 2015-2026 Apple Inc. and the FoundationDB project authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.apple.foundationdb.relational.recordlayer.query;
+
+import com.apple.foundationdb.annotation.API;
+import com.apple.foundationdb.annotation.SpotBugsSuppressWarnings;
+import com.apple.foundationdb.record.query.plan.RecordQueryPlannerConfiguration;
+import com.apple.foundationdb.record.query.plan.cascades.costing.CascadesCostModel;
+import com.apple.foundationdb.record.query.plan.cascades.costing.PickRightTiebreaker;
+import com.apple.foundationdb.record.query.plan.cascades.costing.PlanOpsMap;
+import com.apple.foundationdb.record.query.plan.cascades.costing.PlanningCostModel;
+import com.apple.foundationdb.record.query.plan.cascades.costing.Tiebreaker;
+import com.apple.foundationdb.record.query.plan.cascades.expressions.RelationalExpression;
+import com.apple.foundationdb.record.query.plan.plans.RecordQueryPlan;
+import com.google.common.collect.ImmutableList;
+
+import javax.annotation.Nonnull;
+import java.util.Comparator;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Picks between the plans held in one plan cache entry. It is {@link PlanningCostModel} minus the tiebreakers that
+ * consult a plan operator's match candidate, which a plan no longer carries once it has left the planner.
+ *
+ * The tiebreakers read the {@link RecordQueryPlannerConfiguration} (e.g. whether to prefer an index scan over a
+ * scan), so it must be the one the planner would have used for the query being looked up — the configuration held by
+ * the secondary cache key the entry was found under — or a cached plan could win over one the planner would pick.
+ */
+@API(API.Status.EXPERIMENTAL)
+@SpotBugsSuppressWarnings("SE_COMPARATOR_SHOULD_BE_SERIALIZABLE")
+public class PlanCacheCostModel implements CascadesCostModel, Comparator {
+ @Nonnull
+ private static final Tiebreaker TIEBREAKER =
+ Tiebreaker.combineTiebreakers(ImmutableList.of(
+ PlanningCostModel.lowestNumUnsatisfiedFiltersTiebreaker(),
+ PlanningCostModel.lowestNumDataAccessesTiebreaker(),
+ PlanningCostModel.recursiveCteOperatorTiebreaker(),
+ PlanningCostModel.inOperatorTiebreaker(),
+ PlanningCostModel.primaryScanVsIndexScanTiebreaker(),
+ PlanningCostModel.lowestTypeFilterCountTiebreaker(),
+ PlanningCostModel.deepestTypeFilterPositionTiebreaker(),
+ PlanningCostModel.betterIndexScanTiebreaker(),
+ PlanningCostModel.deepestDistinctTiebreaker(),
+ PlanningCostModel.highestNumInJoinTiebreaker(),
+ PlanningCostModel.lowestNumSimpleOperationsTiebreaker(),
+ PlanningCostModel.lowestNumDefaultOnEmptyTiebreaker(),
+ PlanningCostModel.planHashTiebreaker(),
+ PickRightTiebreaker.pickRightTiebreaker()));
+
+ @Nonnull
+ private final RecordQueryPlannerConfiguration configuration;
+
+ public PlanCacheCostModel(@Nonnull final RecordQueryPlannerConfiguration configuration) {
+ this.configuration = configuration;
+ }
+
+ @Nonnull
+ @Override
+ public RecordQueryPlannerConfiguration getConfiguration() {
+ return configuration;
+ }
+
+ @Nonnull
+ @Override
+ public Optional getBestExpression(@Nonnull final Set extends RelationalExpression> expressions) {
+ return Tiebreaker.ofContext(getConfiguration(), expressions, RecordQueryPlan.class)
+ .thenApply(TIEBREAKER)
+ .getOnlyExpressionMaybe();
+ }
+
+ @Override
+ public int compare(@Nonnull final RecordQueryPlan a, @Nonnull final RecordQueryPlan b) {
+ return TIEBREAKER.compare(getConfiguration(), new PlanOpsMap(a), new PlanOpsMap(b), a, b);
+ }
+}
diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/PlanGenerator.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/PlanGenerator.java
index 51c4eb6e96f..75ded2b445f 100644
--- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/PlanGenerator.java
+++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/PlanGenerator.java
@@ -31,9 +31,9 @@
import com.apple.foundationdb.record.provider.foundationdb.IndexMaintainerFactoryRegistryImpl;
import com.apple.foundationdb.record.provider.foundationdb.IndexMatchCandidateRegistry;
import com.apple.foundationdb.record.query.plan.QueryPlanConstraint;
+import com.apple.foundationdb.record.query.plan.RecordQueryPlannerConfiguration;
import com.apple.foundationdb.record.query.plan.cascades.CascadesPlanner;
import com.apple.foundationdb.record.query.plan.cascades.SemanticException;
-import com.apple.foundationdb.record.query.plan.cascades.costing.StableSelectorCostModel;
import com.apple.foundationdb.record.query.plan.cascades.events.PlannerEventStatsCollector;
import com.apple.foundationdb.record.query.plan.cascades.typing.Type;
import com.apple.foundationdb.record.query.plan.cascades.typing.TypeRepository;
@@ -91,34 +91,39 @@
@API(API.Status.EXPERIMENTAL)
public final class PlanGenerator {
private static final Logger logger = LogManager.getLogger(PlanGenerator.class);
- @Nonnull
- private static final StableSelectorCostModel stableSelectorCostModel = new StableSelectorCostModel();
/**
- * Picks the cheapest of the cached plans that match the lookup, by {@link StableSelectorCostModel}. It captures
- * nothing and holds no per-query state, so a single instance serves every {@code PlanGenerator}.
+ * Picks the cheapest of the cached plans that match the lookup, according to a {@link PlanCacheCostModel} built
+ * from {@code plannerConfiguration}, which must be the configuration of the lookup's secondary cache key. Uses the
+ * cost model as a comparator rather than calling {@link PlanCacheCostModel#getBestExpression} so the chosen
+ * {@link RecordQueryPlan} need not be re-associated with its {@link Plan}.
+ *
+ * @param plannerConfiguration the planner configuration the cost model compares under
+ * @return a reducer selecting the cheapest matching plan
*/
@Nonnull
- private static final Reducer> CHEAPEST_PLAN_REDUCER =
- Reducer.of(plans -> plans.reduce(null, (acc, candidate) -> {
- if (candidate instanceof QueryPlan.PhysicalQueryPlan) {
- final var candidatePhysicalPlan = Assert.castUnchecked(candidate, QueryPlan.PhysicalQueryPlan.class);
- final var candidateQueryPlan = candidatePhysicalPlan.getRecordQueryPlan();
- final var bestQueryPlanSoFar = acc == null ? null : Assert.castUnchecked(acc, QueryPlan.PhysicalQueryPlan.class).getRecordQueryPlan();
- if (bestQueryPlanSoFar == null ||
- // It would be nice to replace this with a call to `stableSelectorCostModel.getBestExpression`
- // This currently can't be done without pulling the RecordQueryPlans out of the QueryPlan objects,
- // running the cost model, and then re-associate it with the original Plan>, which is a bit
- // costly. So, for now, just use the cost model as a comparator
- stableSelectorCostModel.compare(candidateQueryPlan, bestQueryPlanSoFar) < 0) {
- return candidate;
- } else {
- return acc;
- }
- } else {
+ private static Reducer> cheapestPlanReducer(@Nonnull final RecordQueryPlannerConfiguration plannerConfiguration) {
+ final var planCacheCostModel = new PlanCacheCostModel(plannerConfiguration);
+ return Reducer.of(plans -> plans.reduce(null, (acc, candidate) -> {
+ if (candidate instanceof QueryPlan.PhysicalQueryPlan) {
+ final var candidatePhysicalPlan = Assert.castUnchecked(candidate, QueryPlan.PhysicalQueryPlan.class);
+ final var candidateQueryPlan = candidatePhysicalPlan.getRecordQueryPlan();
+ final var bestQueryPlanSoFar = acc == null ? null : Assert.castUnchecked(acc, QueryPlan.PhysicalQueryPlan.class).getRecordQueryPlan();
+ if (bestQueryPlanSoFar == null ||
+ // It would be nice to replace this with a call to `planCacheCostModel.getBestExpression`
+ // This currently can't be done without pulling the RecordQueryPlans out of the QueryPlan objects,
+ // running the cost model, and then re-associate it with the original Plan>, which is a bit
+ // costly. So, for now, just use the cost model as a comparator
+ planCacheCostModel.compare(candidateQueryPlan, bestQueryPlanSoFar) < 0) {
return candidate;
+ } else {
+ return acc;
}
- }));
+ } else {
+ return candidate;
+ }
+ }));
+ }
/**
* An optional plan cache used to improve performance by storing execution plans.
@@ -220,7 +225,7 @@ private Plan> getPlanInternal(@Nonnull String query, @Nonnull KeyValueLogMessa
final Reducer> reducer =
options.getOption(Options.Name.PLAN_CACHE_WRITE_ONLY)
? Reducer.writeOnly()
- : CHEAPEST_PLAN_REDUCER;
+ : cheapestPlanReducer(astHashResult.getQueryCacheKey().getPlannerConfiguration().getRecordQueryPlannerConfiguration());
final var planEquivalence = PhysicalPlanEquivalence.of(astHashResult.getQueryExecutionContext().getEvaluationContext());
return planContext.getMetricsCollector().clock(RelationalMetric.RelationalEvent.CACHE_LOOKUP, () ->
diff --git a/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/PlanCacheCostModelTest.java b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/PlanCacheCostModelTest.java
new file mode 100644
index 00000000000..08d39d31bdf
--- /dev/null
+++ b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/PlanCacheCostModelTest.java
@@ -0,0 +1,132 @@
+/*
+ * PlanCacheCostModelTest.java
+ *
+ * This source file is part of the FoundationDB open source project
+ *
+ * Copyright 2015-2026 Apple Inc. and the FoundationDB project authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.apple.foundationdb.relational.recordlayer.query;
+
+import com.apple.foundationdb.record.IndexScanType;
+import com.apple.foundationdb.record.RecordCoreException;
+import com.apple.foundationdb.record.provider.foundationdb.IndexScanComparisons;
+import com.apple.foundationdb.record.query.expressions.Comparisons;
+import com.apple.foundationdb.record.query.plan.RecordQueryPlannerConfiguration;
+import com.apple.foundationdb.record.query.plan.ScanComparisons;
+import com.apple.foundationdb.record.query.plan.cascades.PlannerStage;
+import com.apple.foundationdb.record.query.plan.cascades.Quantifier;
+import com.apple.foundationdb.record.query.plan.cascades.Reference;
+import com.apple.foundationdb.record.query.plan.cascades.costing.PlanningCostModel;
+import com.apple.foundationdb.record.query.plan.plans.RecordQueryIndexPlan;
+import com.apple.foundationdb.record.query.plan.plans.RecordQueryPlan;
+import com.apple.foundationdb.record.query.plan.plans.RecordQueryPredicatesFilterPlan;
+import com.apple.foundationdb.record.query.plan.plans.RecordQueryScanPlan;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.Objects;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests of {@link PlanCacheCostModel}, which picks between the plans of one plan cache entry.
+ *
+ * @see Issue #4642
+ */
+class PlanCacheCostModelTest {
+ @Nonnull
+ private static final Comparisons.Comparison COMPARISON =
+ new Comparisons.SimpleComparison(Comparisons.Type.GREATER_THAN, 200);
+
+ @Nonnull
+ private final PlanCacheCostModel costModel = new PlanCacheCostModel(RecordQueryPlannerConfiguration.defaultPlannerConfiguration());
+
+ @Nonnull
+ private static RecordQueryPlan indexPlan(@Nonnull final String indexName) {
+ return new RecordQueryIndexPlan(indexName,
+ new IndexScanComparisons(IndexScanType.BY_VALUE, Objects.requireNonNull(ScanComparisons.from(COMPARISON))),
+ false);
+ }
+
+ /** The shape of the unconstrained plan that poisons an entry: a full scan with a residual filter. */
+ @Nonnull
+ private static RecordQueryPlan scanWithResidualFilter() {
+ final var qun = Quantifier.physical(Reference.ofFinalExpression(PlannerStage.PLANNED,
+ new RecordQueryScanPlan(ScanComparisons.EMPTY, false)));
+ return new RecordQueryPredicatesFilterPlan(qun,
+ ImmutableList.of(qun.getFlowedObjectValue().withComparison(COMPARISON)));
+ }
+
+ @Test
+ void prefersIndexScanOverScanWithResidualFilter() {
+ final RecordQueryPlan indexScan = indexPlan("someIndex");
+ final RecordQueryPlan poison = scanWithResidualFilter();
+
+ assertThat(costModel.compare(indexScan, poison)).isNegative();
+ assertThat(costModel.compare(poison, indexScan)).isPositive();
+ assertThat(bestOf(indexScan, poison)).isSameAs(indexScan);
+ assertThat(bestOf(poison, indexScan)).isSameAs(indexScan);
+ }
+
+ /** Cached plans carry no match candidate, so {@link PlanningCostModel} cannot be used here. */
+ @Test
+ void doesNotRequireMatchCandidate() {
+ final Set plans = ImmutableSet.of(indexPlan("someIndex"), indexPlan("someOtherIndex"));
+
+ assertThatThrownBy(() -> new PlanningCostModel(costModel.getConfiguration()).getBestExpression(plans))
+ .isInstanceOf(RecordCoreException.class)
+ .hasMessageContaining("expected match candidate to be present");
+
+ assertThat(costModel.getBestExpression(plans)).isPresent();
+ }
+
+ @Test
+ void compareAndCostingAgree() {
+ final Set plans =
+ ImmutableSet.of(indexPlan("someIndex"), indexPlan("someOtherIndex"), scanWithResidualFilter());
+
+ assertThat(costModel.getBestExpression(plans).orElse(null))
+ .isSameAs(bestOf(plans.toArray(new RecordQueryPlan[0])));
+ }
+
+ @Test
+ void emptySet() {
+ assertThat(costModel.getBestExpression(ImmutableSet.of())).isEmpty();
+ }
+
+ @Test
+ void singleElementSet() {
+ final RecordQueryPlan plan = indexPlan("someIndex");
+ assertThat(costModel.getBestExpression(ImmutableSet.of(plan))).contains(plan);
+ }
+
+ /** Mirrors the reduction the plan cache performs over the plans of one entry. */
+ @Nullable
+ private RecordQueryPlan bestOf(@Nonnull final RecordQueryPlan... plans) {
+ RecordQueryPlan bestSoFar = null;
+ for (final RecordQueryPlan plan : plans) {
+ if (bestSoFar == null || costModel.compare(plan, bestSoFar) < 0) {
+ bestSoFar = plan;
+ }
+ }
+ return bestSoFar;
+ }
+}
diff --git a/yaml-tests/src/test/java/YamlIntegrationTests.java b/yaml-tests/src/test/java/YamlIntegrationTests.java
index 3ac9d0b4ab0..cec153558ab 100644
--- a/yaml-tests/src/test/java/YamlIntegrationTests.java
+++ b/yaml-tests/src/test/java/YamlIntegrationTests.java
@@ -120,6 +120,11 @@ void boolLiteralPredicate(YamlTest.Runner runner) throws Exception {
runner.runYamsql("boolean-ddl.yamsql");
}
+ @TestTemplate
+ void cachedPlanCosting(YamlTest.Runner runner) throws Exception {
+ runner.runYamsql("cached-plan-costing.yamsql");
+ }
+
@TestTemplate
void bytes(YamlTest.Runner runner) throws Exception {
runner.runYamsql("bytes.yamsql");
diff --git a/yaml-tests/src/test/resources/cached-plan-costing.metrics.binpb b/yaml-tests/src/test/resources/cached-plan-costing.metrics.binpb
new file mode 100644
index 00000000000..9d12438c082
--- /dev/null
+++ b/yaml-tests/src/test/resources/cached-plan-costing.metrics.binpb
@@ -0,0 +1,68 @@
+ì
+F
+cached-plan-costing-tests)EXPLAIN SELECT "x" FROM T WHERE "x" > 250¡
+Áųì;\ йÞ&(0é¿á8!@iCOVERING(IDX_X_GT_200 [[GREATER_THAN promote(@c7 AS INT)]] -> [id: KEY:[2], x: KEY:[0]]) | MAP (_.x AS x)—
+digraph G {
+ fontname=courier;
+ rankdir=BT;
+ splines=line;
+ 1 [ label=<| Value Computation |
| MAP (q41.x AS x) |
> color="black" shape="plain" style="solid" fillcolor="black" fontname="courier" fontsize="8" tooltip="RELATION(INT AS x)" ];
+ 2 [ label=<| Covering Index Scan |
| comparisons: [[GREATER_THAN promote(@c7 AS INT)]] |
> color="black" shape="plain" style="solid" fillcolor="black" fontname="courier" fontsize="8" tooltip="RELATION(INT AS id, INT AS x)" ];
+ 3 [ label=<> color="black" shape="plain" style="filled" fillcolor="lightblue" fontname="courier" fontsize="8" tooltip="RELATION(INT AS id, INT AS x)" ];
+ 3 -> 2 [ color="gray20" style="solid" fontname="courier" fontsize="8" arrowhead="normal" arrowtail="none" dir="both" ];
+ 2 -> 1 [ label=< q41> label="q41" color="gray20" style="bold" fontname="courier" fontsize="8" arrowhead="normal" arrowtail="none" dir="both" ];
+}�
+F
+cached-plan-costing-tests)EXPLAIN SELECT "x" FROM T WHERE "x" > 100Ò
+Þ“ÁÞ.B ƒëð(0Õ’‘8@KSCAN([IS T]) | FILTER _.x GREATER_THAN promote(@c7 AS INT) | MAP (_.x AS x)æ
digraph G {
+ fontname=courier;
+ rankdir=BT;
+ splines=line;
+ 1 [ label=<| Value Computation |
| MAP (q29.x AS x) |
> color="black" shape="plain" style="solid" fillcolor="black" fontname="courier" fontsize="8" tooltip="RELATION(INT AS x)" ];
+ 2 [ label=<| Predicate Filter |
| WHERE q2.x GREATER_THAN promote(@c7 AS INT) |
> color="black" shape="plain" style="solid" fillcolor="black" fontname="courier" fontsize="8" tooltip="RELATION(INT AS id, INT AS x)" ];
+ 3 [ label=<> color="black" shape="plain" style="solid" fillcolor="black" fontname="courier" fontsize="8" tooltip="RELATION(INT AS id, INT AS x)" ];
+ 4 [ label=<| Primary Storage |
| record types: [T] |
> color="black" shape="plain" style="filled" fillcolor="lightblue" fontname="courier" fontsize="8" tooltip="RELATION(INT AS id, INT AS x)" ];
+ 3 -> 2 [ label=< q2> label="q2" color="gray20" style="bold" fontname="courier" fontsize="8" arrowhead="normal" arrowtail="none" dir="both" ];
+ 4 -> 3 [ color="gray20" style="solid" fontname="courier" fontsize="8" arrowhead="normal" arrowtail="none" dir="both" ];
+ 2 -> 1 [ label=< q29> label="q29" color="gray20" style="bold" fontname="courier" fontsize="8" arrowhead="normal" arrowtail="none" dir="both" ];
+}ë
+F
+cached-plan-costing-tests)EXPLAIN SELECT "x" FROM T WHERE "x" > 300
+Áǵ\ ذÑ(0ùùL8!@iCOVERING(IDX_X_GT_200 [[GREATER_THAN promote(@c7 AS INT)]] -> [id: KEY:[2], x: KEY:[0]]) | MAP (_.x AS x)—
+digraph G {
+ fontname=courier;
+ rankdir=BT;
+ splines=line;
+ 1 [ label=<| Value Computation |
| MAP (q41.x AS x) |
> color="black" shape="plain" style="solid" fillcolor="black" fontname="courier" fontsize="8" tooltip="RELATION(INT AS x)" ];
+ 2 [ label=<| Covering Index Scan |
| comparisons: [[GREATER_THAN promote(@c7 AS INT)]] |
> color="black" shape="plain" style="solid" fillcolor="black" fontname="courier" fontsize="8" tooltip="RELATION(INT AS id, INT AS x)" ];
+ 3 [ label=<> color="black" shape="plain" style="filled" fillcolor="lightblue" fontname="courier" fontsize="8" tooltip="RELATION(INT AS id, INT AS x)" ];
+ 3 -> 2 [ color="gray20" style="solid" fontname="courier" fontsize="8" arrowhead="normal" arrowtail="none" dir="both" ];
+ 2 -> 1 [ label=< q41> label="q41" color="gray20" style="bold" fontname="courier" fontsize="8" arrowhead="normal" arrowtail="none" dir="both" ];
+}œ
+F
+cached-plan-costing-tests)EXPLAIN SELECT "x" FROM T WHERE "x" > 150Ñ
+ÞÛÑ—B €ˆž(0£78@KSCAN([IS T]) | FILTER _.x GREATER_THAN promote(@c7 AS INT) | MAP (_.x AS x)æ
digraph G {
+ fontname=courier;
+ rankdir=BT;
+ splines=line;
+ 1 [ label=<| Value Computation |
| MAP (q29.x AS x) |
> color="black" shape="plain" style="solid" fillcolor="black" fontname="courier" fontsize="8" tooltip="RELATION(INT AS x)" ];
+ 2 [ label=<| Predicate Filter |
| WHERE q2.x GREATER_THAN promote(@c7 AS INT) |
> color="black" shape="plain" style="solid" fillcolor="black" fontname="courier" fontsize="8" tooltip="RELATION(INT AS id, INT AS x)" ];
+ 3 [ label=<> color="black" shape="plain" style="solid" fillcolor="black" fontname="courier" fontsize="8" tooltip="RELATION(INT AS id, INT AS x)" ];
+ 4 [ label=<| Primary Storage |
| record types: [T] |
> color="black" shape="plain" style="filled" fillcolor="lightblue" fontname="courier" fontsize="8" tooltip="RELATION(INT AS id, INT AS x)" ];
+ 3 -> 2 [ label=< q2> label="q2" color="gray20" style="bold" fontname="courier" fontsize="8" arrowhead="normal" arrowtail="none" dir="both" ];
+ 4 -> 3 [ color="gray20" style="solid" fontname="courier" fontsize="8" arrowhead="normal" arrowtail="none" dir="both" ];
+ 2 -> 1 [ label=< q29> label="q29" color="gray20" style="bold" fontname="courier" fontsize="8" arrowhead="normal" arrowtail="none" dir="both" ];
+}ë
+F
+cached-plan-costing-tests)EXPLAIN SELECT "x" FROM T WHERE "x" > 260
+Áǵ\ ذÑ(0ùùL8!@iCOVERING(IDX_X_GT_200 [[GREATER_THAN promote(@c7 AS INT)]] -> [id: KEY:[2], x: KEY:[0]]) | MAP (_.x AS x)—
+digraph G {
+ fontname=courier;
+ rankdir=BT;
+ splines=line;
+ 1 [ label=<| Value Computation |
| MAP (q41.x AS x) |
> color="black" shape="plain" style="solid" fillcolor="black" fontname="courier" fontsize="8" tooltip="RELATION(INT AS x)" ];
+ 2 [ label=<| Covering Index Scan |
| comparisons: [[GREATER_THAN promote(@c7 AS INT)]] |
> color="black" shape="plain" style="solid" fillcolor="black" fontname="courier" fontsize="8" tooltip="RELATION(INT AS id, INT AS x)" ];
+ 3 [ label=<> color="black" shape="plain" style="filled" fillcolor="lightblue" fontname="courier" fontsize="8" tooltip="RELATION(INT AS id, INT AS x)" ];
+ 3 -> 2 [ color="gray20" style="solid" fontname="courier" fontsize="8" arrowhead="normal" arrowtail="none" dir="both" ];
+ 2 -> 1 [ label=< q41> label="q41" color="gray20" style="bold" fontname="courier" fontsize="8" arrowhead="normal" arrowtail="none" dir="both" ];
+}
\ No newline at end of file
diff --git a/yaml-tests/src/test/resources/cached-plan-costing.metrics.yaml b/yaml-tests/src/test/resources/cached-plan-costing.metrics.yaml
new file mode 100644
index 00000000000..c7b7e227926
--- /dev/null
+++ b/yaml-tests/src/test/resources/cached-plan-costing.metrics.yaml
@@ -0,0 +1,61 @@
+cached-plan-costing-tests:
+- query: EXPLAIN SELECT "x" FROM T WHERE "x" > 250
+ ref: cached-plan-costing.yamsql:41
+ explain: 'COVERING(IDX_X_GT_200 [[GREATER_THAN promote(@c7 AS INT)]] -> [id: KEY:[2],
+ x: KEY:[0]]) | MAP (_.x AS x)'
+ task_count: 321
+ task_total_time_ms: 125
+ transform_count: 92
+ transform_time_ms: 81
+ transform_yield_count: 27
+ insert_time_ms: 7
+ insert_new_count: 33
+ insert_reused_count: 2
+- query: EXPLAIN SELECT "x" FROM T WHERE "x" > 100
+ ref: cached-plan-costing.yamsql:46
+ explain: SCAN([IS T]) | FILTER _.x GREATER_THAN promote(@c7 AS INT) | MAP (_.x
+ AS x)
+ task_count: 222
+ task_total_time_ms: 98
+ transform_count: 66
+ transform_time_ms: 66
+ transform_yield_count: 19
+ insert_time_ms: 6
+ insert_new_count: 22
+ insert_reused_count: 2
+- query: EXPLAIN SELECT "x" FROM T WHERE "x" > 300
+ ref: cached-plan-costing.yamsql:51
+ explain: 'COVERING(IDX_X_GT_200 [[GREATER_THAN promote(@c7 AS INT)]] -> [id: KEY:[2],
+ x: KEY:[0]]) | MAP (_.x AS x)'
+ task_count: 321
+ task_total_time_ms: 17
+ transform_count: 92
+ transform_time_ms: 7
+ transform_yield_count: 27
+ insert_time_ms: 1
+ insert_new_count: 33
+ insert_reused_count: 2
+- query: EXPLAIN SELECT "x" FROM T WHERE "x" > 150
+ ref: cached-plan-costing.yamsql:56
+ explain: SCAN([IS T]) | FILTER _.x GREATER_THAN promote(@c7 AS INT) | MAP (_.x
+ AS x)
+ task_count: 222
+ task_total_time_ms: 12
+ transform_count: 66
+ transform_time_ms: 6
+ transform_yield_count: 19
+ insert_time_ms: 0
+ insert_new_count: 22
+ insert_reused_count: 2
+- query: EXPLAIN SELECT "x" FROM T WHERE "x" > 260
+ ref: cached-plan-costing.yamsql:61
+ explain: 'COVERING(IDX_X_GT_200 [[GREATER_THAN promote(@c7 AS INT)]] -> [id: KEY:[2],
+ x: KEY:[0]]) | MAP (_.x AS x)'
+ task_count: 321
+ task_total_time_ms: 17
+ transform_count: 92
+ transform_time_ms: 7
+ transform_yield_count: 27
+ insert_time_ms: 1
+ insert_new_count: 33
+ insert_reused_count: 2
diff --git a/yaml-tests/src/test/resources/cached-plan-costing.yamsql b/yaml-tests/src/test/resources/cached-plan-costing.yamsql
new file mode 100644
index 00000000000..6adfee8e31d
--- /dev/null
+++ b/yaml-tests/src/test/resources/cached-plan-costing.yamsql
@@ -0,0 +1,63 @@
+#
+# cached-plan-costing.yamsql
+#
+# This source file is part of the FoundationDB open source project
+#
+# Copyright 2015-2026 Apple Inc. and the FoundationDB project authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+---
+options:
+ # Steps 3 and 5 only became covering once the plan cache stopped picking by plan hash, see
+ # https://github.com/FoundationDB/fdb-record-layer/issues/4642
+ supported_version: !current_version
+---
+schema_template:
+ CREATE TABLE T ("id" INTEGER, "x" INTEGER, PRIMARY KEY ("id"))
+ CREATE INDEX idx_x_gt_200 AS SELECT "x" FROM T WHERE "x" > 200 ORDER BY "x"
+---
+setup:
+ steps:
+ - query: INSERT INTO T VALUES (1, 50), (2, 150), (3, 220), (4, 260), (5, 320), (6, 400)
+---
+test_block:
+ name: cached-plan-costing-tests
+ preset: single_repetition_ordered
+ tests:
+ -
+ # Implies the index predicate: the covering plan is cached.
+ - query: SELECT "x" FROM T WHERE "x" > 250
+ - explain: "COVERING(IDX_X_GT_200 [[GREATER_THAN promote(@c7 AS INT)]] -> [id: KEY:[2], x: KEY:[0]]) | MAP (_.x AS x)"
+ - unorderedResult: [{x: 260}, {x: 320}, {x: 400}]
+ -
+ # Does not imply it: the unconstrained scan joins the entry -- the poison.
+ - query: SELECT "x" FROM T WHERE "x" > 100
+ - explain: "SCAN([IS T]) | FILTER _.x GREATER_THAN promote(@c7 AS INT) | MAP (_.x AS x)"
+ - unorderedResult: [{x: 150}, {x: 220}, {x: 260}, {x: 320}, {x: 400}]
+ -
+ # Both match; the covering one has no residual filter, so it must win.
+ - query: SELECT "x" FROM T WHERE "x" > 300
+ - explain: "COVERING(IDX_X_GT_200 [[GREATER_THAN promote(@c7 AS INT)]] -> [id: KEY:[2], x: KEY:[0]]) | MAP (_.x AS x)"
+ - unorderedResult: [{x: 320}, {x: 400}]
+ -
+ # No over-correction: still gets the only plan it qualifies for.
+ - query: SELECT "x" FROM T WHERE "x" > 150
+ - explain: "SCAN([IS T]) | FILTER _.x GREATER_THAN promote(@c7 AS INT) | MAP (_.x AS x)"
+ - unorderedResult: [{x: 220}, {x: 260}, {x: 320}, {x: 400}]
+ -
+ # The choice is stable.
+ - query: SELECT "x" FROM T WHERE "x" > 260
+ - explain: "COVERING(IDX_X_GT_200 [[GREATER_THAN promote(@c7 AS INT)]] -> [id: KEY:[2], x: KEY:[0]]) | MAP (_.x AS x)"
+ - unorderedResult: [{x: 320}, {x: 400}]
+...