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 expressions) { - return costExpressions(expressions).getOnlyExpressionMaybe(); - } - - @Nonnull - private TiebreakerResult costExpressions(@Nonnull final Set 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 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 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=<
Index
IDX_X_GT_200
> 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=<
Scan
comparisons: [IS T]
> 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=<
Index
IDX_X_GT_200
> 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=<
Scan
comparisons: [IS T]
> 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=<
Index
IDX_X_GT_200
> 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}] +...