Skip to content

Commit 5caafc5

Browse files
authored
Planner now uses correct calculation for ordering of aggregate scans over permuted min/max indexes (#3333)
The way the ordering key is generated for permuted min and max indexes requires that the planner handle the permutation of the min/max aggregate with zero or more grouping columns. We were not correctly handling that, which meant that we were crafting incorrect planner keys. This would lead to strange comparison keys with IN-union plans, and it could lead to `ORDER BY` constraints not being matched. This handles that appropriately, with some additional tests to make sure we are now considering more interesting examples of queries with min or max indexes and ordering constraints. This fixes #3331.
1 parent 0ab85ce commit 5caafc5

File tree

5 files changed

+1273
-201
lines changed

5 files changed

+1273
-201
lines changed

fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/AggregateIndexMatchCandidate.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,15 +245,27 @@ public List<MatchedOrderingPart> computeMatchedOrderingParts(@Nonnull final Matc
245245

246246
private int indexWithPermutation(int i) {
247247
if (isPermuted()) {
248+
// There are groupingKeyExpression.getColumnSize() = groupingCount + groupedCount columns in total.
249+
// This needs to map the position in the permuted ordering of columns to their ordering in the
250+
// original expression. The permutation process takes the final permutedCount elements of the
251+
// grouping columns and moves them to the end. So, for groupingCount = 4, groupedCount = 2,
252+
// and permutedCount = 3, that might look like:
253+
// [0, 1, 2, 3, 4, 5] -> [0, 4, 5, 1, 2, 3]
254+
// So, the first (groupingCount - permutedCount) columns stay the same. The next groupedCount
255+
// columns in the permuted list have been shifted over permutedCount columns from the right,
256+
// so we add permutedCount to get their original position. The rest of the columns
257+
// were moved over groupedCount columns from the left, so we subtract groupedCount to get
258+
// their original position
248259
int permutedCount = getPermutedCount();
249260
final GroupingKeyExpression groupingKeyExpression = (GroupingKeyExpression)index.getRootExpression();
250261
int groupingCount = groupingKeyExpression.getGroupingCount();
262+
int groupedCount = groupingKeyExpression.getGroupedCount();
251263
if ( i < groupingCount - permutedCount) {
252264
return i;
253-
} else if (i >= groupingCount - permutedCount && i < groupingCount - permutedCount + groupingKeyExpression.getGroupedCount()) {
265+
} else if (i < groupingCount - permutedCount + groupedCount) {
254266
return i + permutedCount;
255267
} else {
256-
return i - permutedCount;
268+
return i - groupedCount;
257269
}
258270
} else {
259271
return i;

fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/provider/foundationdb/query/FDBPermutedMinMaxQueryTest.java

Lines changed: 175 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@
4040
import com.apple.foundationdb.record.query.plan.cascades.expressions.ExplodeExpression;
4141
import com.apple.foundationdb.record.query.plan.cascades.expressions.GroupByExpression;
4242
import com.apple.foundationdb.record.query.plan.cascades.expressions.LogicalSortExpression;
43+
import com.apple.foundationdb.record.query.plan.cascades.matching.structure.BindingMatcher;
4344
import com.apple.foundationdb.record.query.plan.cascades.matching.structure.ListMatcher;
4445
import com.apple.foundationdb.record.query.plan.cascades.matching.structure.RecordQueryPlanMatchers;
46+
import com.apple.foundationdb.record.query.plan.cascades.matching.structure.ValueMatchers;
4547
import com.apple.foundationdb.record.query.plan.cascades.predicates.QueryPredicate;
4648
import com.apple.foundationdb.record.query.plan.cascades.typing.Type;
4749
import com.apple.foundationdb.record.query.plan.cascades.values.ConstantObjectValue;
@@ -57,6 +59,7 @@
5759
import com.apple.test.Tags;
5860
import com.google.common.base.Predicates;
5961
import com.google.common.collect.ImmutableList;
62+
import com.google.common.collect.ImmutableMap;
6063
import com.google.protobuf.Descriptors;
6164
import com.google.protobuf.Message;
6265
import org.hamcrest.Matcher;
@@ -77,6 +80,7 @@
7780
import java.util.Map;
7881
import java.util.Optional;
7982
import java.util.Set;
83+
import java.util.concurrent.atomic.AtomicLong;
8084
import java.util.function.Function;
8185
import java.util.function.Predicate;
8286
import java.util.stream.Collectors;
@@ -94,6 +98,7 @@
9498
import static org.hamcrest.Matchers.contains;
9599
import static org.hamcrest.Matchers.containsInAnyOrder;
96100
import static org.hamcrest.Matchers.hasSize;
101+
import static org.hamcrest.Matchers.lessThanOrEqualTo;
97102
import static org.junit.jupiter.api.Assertions.assertEquals;
98103
import static org.junit.jupiter.api.Assertions.assertTrue;
99104

@@ -456,7 +461,9 @@ void selectMaxWithInOrderByMax(InComparisonCase inComparisonCase) throws Excepti
456461
.where(RecordQueryPlanMatchers.scanComparisons(ScanComparisons.equalities(ListMatcher.exactly(ScanComparisons.anyValueComparison()))))
457462
.and(RecordQueryPlanMatchers.isReverse())
458463
)
459-
));
464+
).where(RecordQueryPlanMatchers.comparisonKeyValues(ListMatcher.exactly(
465+
ValueMatchers.fieldValueWithFieldNames("m"), ValueMatchers.fieldValueWithFieldNames("num_value_2"), ValueMatchers.fieldValueWithFieldNames("num_value_3_indexed")
466+
))));
460467

461468
assertEquals(inComparisonCase.getContinuationPlanHash(), plan.planHash(PlanHashable.CURRENT_FOR_CONTINUATION));
462469

@@ -537,7 +544,9 @@ void testMaxWithInAndDupes(InComparisonCase inComparisonCase) throws Exception {
537544
.where(RecordQueryPlanMatchers.scanComparisons(ScanComparisons.equalities(ListMatcher.exactly(ScanComparisons.anyValueComparison()))))
538545
.and(RecordQueryPlanMatchers.isReverse())
539546
)
540-
));
547+
).where(RecordQueryPlanMatchers.comparisonKeyValues(ListMatcher.exactly(
548+
ValueMatchers.fieldValueWithFieldNames("m"), ValueMatchers.fieldValueWithFieldNames("str_value_indexed"), ValueMatchers.fieldValueWithFieldNames("num_value_3_indexed")
549+
))));
541550

542551
assertEquals(inComparisonCase.getContinuationPlanHash(), plan.planHash(PlanHashable.CURRENT_FOR_CONTINUATION));
543552

@@ -690,7 +699,9 @@ void testSortedMaxWithInOnRepeater(InComparisonCase inComparisonCase) {
690699
RecordQueryPlanMatchers.aggregateIndexPlan()
691700
.where(RecordQueryPlanMatchers.isReverse())
692701
)
693-
));
702+
).where(RecordQueryPlanMatchers.comparisonKeyValues(ListMatcher.exactly(
703+
ValueMatchers.fieldValueWithFieldNames("m"), ValueMatchers.fieldValueWithFieldNames("x"), ValueMatchers.fieldValueWithFieldNames("num_value_2"))
704+
)));
694705

695706
assertEquals(inComparisonCase.getContinuationPlanHash(), plan.planHash(PlanHashable.CURRENT_FOR_CONTINUATION));
696707

@@ -734,6 +745,154 @@ void testSortedMaxWithInOnRepeater(InComparisonCase inComparisonCase) {
734745
}
735746
}
736747

748+
@Nonnull
749+
static Stream<InComparisonCase> testMaxUniqueByStr2And3WithDifferentOrderingKeys() {
750+
ConstantObjectValue constant = ConstantObjectValue.of(Quantifier.uniqueID(), "0", new Type.Array(false, Type.primitiveType(Type.TypeCode.STRING, false)));
751+
List<String> literalStrList = ImmutableList.of("even", "odd", "empty", "other1", "other2");
752+
return Stream.of(
753+
new InComparisonCase("byParameter", new Comparisons.ParameterComparison(Comparisons.Type.IN, "strValueList"), strValueList -> Bindings.newBuilder().set("strValueList", strValueList).build(), 755361732, -85959138),
754+
new InComparisonCase("byLiteral", new Comparisons.ListComparison(Comparisons.Type.IN, literalStrList), strValueList -> {
755+
Assumptions.assumeTrue(strValueList.equals(literalStrList));
756+
return Bindings.EMPTY_BINDINGS;
757+
}, 381518259, -459802611),
758+
new InComparisonCase("byConstantObjectValue", new Comparisons.ValueComparison(Comparisons.Type.IN, constant), strValueList -> constantBindings(constant, strValueList), -603175313, -1444496183)
759+
);
760+
}
761+
762+
@DualPlannerTest(planner = DualPlannerTest.Planner.CASCADES)
763+
@ParameterizedTest(name = "testMaxUniqueByStr2And3WithDifferentOrderingKeys[inComparisonCase={0}]")
764+
@MethodSource
765+
void testMaxUniqueByStr2And3WithDifferentOrderingKeys(InComparisonCase inComparisonCase) throws Exception {
766+
Assumptions.assumeTrue(useCascadesPlanner);
767+
final RecordMetaDataHook hook = metaData -> {
768+
metaData.addIndex(metaData.getRecordType("MySimpleRecord"), maxUniqueByStrValueOrderBy2And3());
769+
metaData.removeIndex("MySimpleRecord$num_value_unique"); // get rid of unique index as we don't want uniqueness constraint
770+
};
771+
complexQuerySetup(hook);
772+
773+
try (FDBRecordContext context = openContext()) {
774+
openSimpleRecordStore(context, hook);
775+
776+
// Add in a few more records. These will serve to create duplicates with the records included in the
777+
// complex query setup hook
778+
final Map<Tuple, Integer> expected = expectedMaxUniquesByStrValueNumValue2NumValue3();
779+
final AtomicLong recNoCounter = new AtomicLong(100_000L);
780+
saveOtherMax("other1", 980, 0, 0, recNoCounter, expected);
781+
saveOtherMax("other2", 980, 1, 0, recNoCounter, expected);
782+
saveOtherMax("other1", 992, 2, 0, recNoCounter, expected);
783+
saveOtherMax("other2", 992, 2, 1, recNoCounter, expected);
784+
saveOtherMax("other1", 972, 1, 3, recNoCounter, expected);
785+
saveOtherMax("other2", 972, 2, 3, recNoCounter, expected);
786+
787+
final Object[] expectedArr = expected.entrySet().stream()
788+
.map(entry -> {
789+
Tuple key = entry.getKey();
790+
return ImmutableMap.<String, Object>of(
791+
"str_value_indexed", key.getString(0),
792+
"num_value_2", (int) key.getLong(1),
793+
"num_value_3_indexed", (int) key.getLong(2),
794+
"m", entry.getValue());
795+
})
796+
.toArray();
797+
798+
planner.setConfiguration(planner.getConfiguration().asBuilder().setAttemptFailedInJoinAsUnionMaxSize(10).build());
799+
800+
// Issue a query like:
801+
// SELECT str_value_indexed, max(num_value_unique) as m, num_value_2, num_value_3_indexed
802+
// FROM MySimpleRecord
803+
// GROUP BY str_value_indexed, num_value_2_indexed, num_value_3
804+
// HAVING str_value_indexed IN $strValueList
805+
// ORDER BY max(num_value_unique), num_value_2_indexed, num_value_3
806+
// We will issue this with different ordering keys
807+
808+
final List<String> totalOrderingKey = ImmutableList.of("m", "num_value_2", "num_value_3_indexed");
809+
boolean checkedPlanHash = false;
810+
for (int i = 0; i <= totalOrderingKey.size(); i++) {
811+
final List<String> orderingKey = totalOrderingKey.subList(0, i);
812+
813+
final RecordQueryPlan plan = planGraph(() -> {
814+
final var base = fullTypeScan(recordStore.getRecordMetaData(), "MySimpleRecord");
815+
final var selectWhere = selectWhereQun(base, null);
816+
final var groupedByQun = maxByGroup(selectWhere, "num_value_unique", ImmutableList.of("str_value_indexed", "num_value_2", "num_value_3_indexed"));
817+
818+
final var strValueReference = FieldValue.ofOrdinalNumberAndFuseIfPossible(FieldValue.ofOrdinalNumber(groupedByQun.getFlowedObjectValue(), 0), 0);
819+
final var qun = selectHaving(groupedByQun,
820+
strValueReference.withComparison(inComparisonCase.getComparison()),
821+
ImmutableList.of("str_value_indexed", "m", "num_value_2", "num_value_3_indexed"));
822+
final AliasMap aliasMap = AliasMap.ofAliases(qun.getAlias(), Quantifier.current());
823+
final List<Value> sortValues = orderingKey.stream()
824+
.map(fieldName -> FieldValue.ofFieldName(qun.getFlowedObjectValue(), fieldName).rebase(aliasMap))
825+
.collect(Collectors.toList());
826+
return Reference.initialOf(sortExpression(sortValues, true, qun));
827+
});
828+
829+
var aggregatePlanMatcher = RecordQueryPlanMatchers.aggregateIndexPlan()
830+
.where(RecordQueryPlanMatchers.scanComparisons(ScanComparisons.equalities(ListMatcher.exactly(ScanComparisons.anyValueComparison()))));
831+
if (orderingKey.isEmpty()) {
832+
// If we don't have an ordering constraint, use an IN-join.
833+
assertMatchesExactly(plan,
834+
RecordQueryPlanMatchers.inJoinPlan(
835+
RecordQueryPlanMatchers.mapPlan(aggregatePlanMatcher)));
836+
} else {
837+
// If we do have an ordering constraint, we need to use an IN-union.
838+
// The ordering key should be:
839+
// 1. Fields in the requested order
840+
// 2. The str_value_indexed field (meaning that we'll consume all values from a leg of the union for a fixed value of the ordering key)
841+
// 3. Any remaining elements in the underlying order
842+
final List<BindingMatcher<FieldValue>> comparisonKeyFieldMatchers = Stream.concat(
843+
orderingKey.stream(),
844+
Stream.concat(Stream.of("str_value_indexed"), totalOrderingKey.subList(i, totalOrderingKey.size()).stream())
845+
).map(ValueMatchers::fieldValueWithFieldNames).collect(Collectors.toList());
846+
assertMatchesExactly(plan,
847+
RecordQueryPlanMatchers.inUnionOnValuesPlan(
848+
RecordQueryPlanMatchers.mapPlan(
849+
aggregatePlanMatcher.and(RecordQueryPlanMatchers.isReverse())))
850+
.where(RecordQueryPlanMatchers.comparisonKeyValues(ListMatcher.exactly(comparisonKeyFieldMatchers)))
851+
);
852+
}
853+
854+
// When we have a total ordering, compare the plan hashes. We only check this one plan because we only encode
855+
// one plan hash overall, and we've hard-coded the one with the largest comparison key
856+
if (orderingKey.size() == totalOrderingKey.size()) {
857+
assertEquals(inComparisonCase.getContinuationPlanHash(), plan.planHash(PlanHashable.CURRENT_FOR_CONTINUATION));
858+
assertEquals(inComparisonCase.getLegacyPlanHash(), plan.planHash(PlanHashable.CURRENT_LEGACY));
859+
checkedPlanHash = true;
860+
}
861+
862+
// Validate contents
863+
final List<Map<String, Object>> queriedList = queryAsMaps(plan, inComparisonCase.getBindings(ImmutableList.of("even", "odd", "empty", "other1", "other2")));
864+
assertThat(queriedList, containsInAnyOrder(expectedArr));
865+
866+
// Validate ordering
867+
Tuple lastSeen = null;
868+
for (Map<String, Object> queried : queriedList) {
869+
Tuple comparisonKey = Tuple.fromItems(orderingKey.stream().map(queried::get).collect(Collectors.toList()));
870+
if (lastSeen != null) {
871+
assertThat(comparisonKey, lessThanOrEqualTo(lastSeen));
872+
}
873+
lastSeen = comparisonKey;
874+
}
875+
}
876+
877+
// Validate that we actually checked the plan hash. This also double checks that we try the full comparison
878+
// key case, which is also the case that exposes the bug alluded to by:
879+
// https://github.com/FoundationDB/fdb-record-layer/issues/3331
880+
assertTrue(checkedPlanHash, "should have checked plan hashes during test");
881+
}
882+
}
883+
884+
private void saveOtherMax(@Nonnull String strValue, int numValueUnique, int numValue2, int numValue3, @Nonnull AtomicLong recNoCounter, @Nonnull Map<Tuple, Integer> collector) {
885+
recordStore.saveRecord(TestRecords1Proto.MySimpleRecord.newBuilder()
886+
.setRecNo(recNoCounter.getAndIncrement())
887+
.setStrValueIndexed(strValue)
888+
.setNumValueUnique(numValueUnique)
889+
.setNumValue2(numValue2)
890+
.setNumValue3Indexed(numValue3)
891+
.build());
892+
collector.compute(Tuple.from(strValue, numValue2, numValue3),
893+
(key, oldMax) -> oldMax == null || oldMax < numValueUnique ? numValueUnique : oldMax);
894+
}
895+
737896
@DualPlannerTest(planner = DualPlannerTest.Planner.CASCADES)
738897
void selectMaxGroupByWithPredicateOnMax() throws Exception {
739898
final RecordMetaDataHook hook = metaData -> metaData.addIndex(metaData.getRecordType("MySimpleRecord"), maxUniqueBy2And3());
@@ -960,6 +1119,19 @@ private void setUpWithRepeaters(@Nullable RecordMetaDataHook hook) {
9601119
}
9611120
}
9621121

1122+
@Nonnull
1123+
private static Map<Tuple, Integer> expectedMaxUniquesByStrValueNumValue2NumValue3() {
1124+
Map<Tuple, Integer> expected = new HashMap<>();
1125+
for (String strValue : List.of("even", "odd")) {
1126+
for (int numValue2 = 0; numValue2 < 3; numValue2++) {
1127+
final int nv2 = numValue2;
1128+
Map<Integer, Integer> maxBy3 = expectedMaxUniquesByNumValue3(x -> x == nv2, strValue::equals);
1129+
maxBy3.forEach((nv3, maxUnique) -> expected.put(Tuple.from(strValue, nv2, nv3), maxUnique));
1130+
}
1131+
}
1132+
return expected;
1133+
}
1134+
9631135
@Nonnull
9641136
private static Map<Integer, Integer> expectedMaxUniquesByNumValue3(Predicate<Integer> numValue2Filter) {
9651137
return expectedMaxUniquesByNumValue3(numValue2Filter, Predicates.alwaysTrue());

0 commit comments

Comments
 (0)