Skip to content

Commit 5de762f

Browse files
authored
feat(flink): Support data skipping based on partitioned RLI (apache#19006)
1 parent c442626 commit 5de762f

8 files changed

Lines changed: 792 additions & 224 deletions

File tree

hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/FileIndex.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929
import org.apache.hudi.index.bucket.BucketIdentifier;
3030
import org.apache.hudi.source.prune.ColumnStatsProbe;
3131
import org.apache.hudi.source.prune.PartitionPruners;
32+
import org.apache.hudi.source.stats.BaseRecordLevelIndex;
3233
import org.apache.hudi.source.stats.FileStatsIndex;
33-
import org.apache.hudi.source.stats.RecordLevelIndex;
3434
import org.apache.hudi.storage.StoragePath;
3535
import org.apache.hudi.storage.StoragePathInfo;
3636
import org.apache.hudi.util.StreamerUtil;
@@ -73,7 +73,7 @@ public class FileIndex implements Serializable, AutoCloseable {
7373
private final Function<String, Integer> partitionBucketIdFunc; // for bucket pruning
7474
private List<String> partitionPaths; // cache of partition paths
7575
private final FileStatsIndex fileStatsIndex; // for data skipping
76-
private final Option<RecordLevelIndex> recordLevelIndex;
76+
private final Option<BaseRecordLevelIndex> recordLevelIndex;
7777
private final HoodieTableMetaClient metaClient;
7878

7979
private FileIndex(
@@ -93,7 +93,7 @@ private FileIndex(
9393
this.fileStatsIndex = new FileStatsIndex(path.toString(), rowType, conf, metaClient);
9494
this.partitionBucketIdFunc = partitionBucketIdFunc;
9595
List<ExpressionEvaluators.Evaluator> evaluators = Option.ofNullable(colStatsProbe).map(ColumnStatsProbe::getEvaluators).orElse(Collections.emptyList());
96-
this.recordLevelIndex = RecordLevelIndex.create(path.toString(), conf, metaClient, evaluators, rowType);
96+
this.recordLevelIndex = BaseRecordLevelIndex.create(path.toString(), conf, metaClient, evaluators, rowType);
9797
this.metaClient = metaClient;
9898
}
9999

@@ -288,7 +288,7 @@ private static double percentage(double total, double left) {
288288
@Override
289289
public void close() {
290290
this.fileStatsIndex.close();
291-
this.recordLevelIndex.ifPresent(RecordLevelIndex::close);
291+
this.recordLevelIndex.ifPresent(BaseRecordLevelIndex::close);
292292
this.partitionPruner.ifPresent(PartitionPruners.PartitionPruner::close);
293293
}
294294

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package org.apache.hudi.source.stats;
20+
21+
import org.apache.hudi.client.common.HoodieFlinkEngineContext;
22+
import org.apache.hudi.common.data.HoodiePairData;
23+
import org.apache.hudi.common.model.FileSlice;
24+
import org.apache.hudi.common.model.HoodieFileGroupId;
25+
import org.apache.hudi.common.model.HoodieIndexDefinition;
26+
import org.apache.hudi.common.model.HoodieRecordGlobalLocation;
27+
import org.apache.hudi.common.table.HoodieTableMetaClient;
28+
import org.apache.hudi.common.util.HoodieDataUtils;
29+
import org.apache.hudi.common.util.Option;
30+
import org.apache.hudi.common.util.VisibleForTesting;
31+
import org.apache.hudi.common.util.collection.Pair;
32+
import org.apache.hudi.configuration.FlinkOptions;
33+
import org.apache.hudi.configuration.OptionsResolver;
34+
import org.apache.hudi.exception.HoodieException;
35+
import org.apache.hudi.index.record.HoodieRecordIndex;
36+
import org.apache.hudi.keygen.KeyGenUtils;
37+
import org.apache.hudi.keygen.KeyGenerator;
38+
import org.apache.hudi.metadata.HoodieTableMetadata;
39+
import org.apache.hudi.metadata.HoodieTableMetadataUtil;
40+
import org.apache.hudi.sink.bulk.RowDataKeyGen;
41+
import org.apache.hudi.source.ExpressionEvaluators;
42+
import org.apache.hudi.util.StreamerUtil;
43+
44+
import lombok.extern.slf4j.Slf4j;
45+
import org.apache.flink.configuration.Configuration;
46+
import org.apache.flink.table.data.TimestampData;
47+
import org.apache.flink.table.types.logical.DecimalType;
48+
import org.apache.flink.table.types.logical.LogicalType;
49+
import org.apache.flink.table.types.logical.RowType;
50+
51+
import java.math.BigDecimal;
52+
import java.util.ArrayList;
53+
import java.util.Arrays;
54+
import java.util.Collections;
55+
import java.util.List;
56+
import java.util.Objects;
57+
import java.util.Set;
58+
import java.util.stream.Collectors;
59+
60+
/**
61+
* Base index support that leverages Record Level Index to prune file slices.
62+
*/
63+
@Slf4j
64+
public abstract class BaseRecordLevelIndex implements FlinkMetadataIndex {
65+
private static final long serialVersionUID = 1L;
66+
67+
private final String basePath;
68+
protected final Configuration conf;
69+
protected final List<String> hoodieKeysFromFilter;
70+
private final HoodieTableMetaClient metaClient;
71+
private HoodieTableMetadata metadataTable;
72+
73+
@VisibleForTesting
74+
BaseRecordLevelIndex(
75+
String basePath,
76+
Configuration conf,
77+
HoodieTableMetaClient metaClient,
78+
List<String> hoodieKeysFromFilter) {
79+
this.basePath = basePath;
80+
this.conf = conf;
81+
this.metaClient = metaClient;
82+
this.hoodieKeysFromFilter = hoodieKeysFromFilter;
83+
}
84+
85+
@Override
86+
public String getIndexPartitionName() {
87+
return HoodieTableMetadataUtil.PARTITION_NAME_RECORD_INDEX;
88+
}
89+
90+
@Override
91+
public boolean isIndexAvailable() {
92+
return metaClient.getTableConfig().isMetadataTableAvailable()
93+
&& metaClient.getTableConfig().getMetadataPartitions().contains(HoodieTableMetadataUtil.PARTITION_NAME_RECORD_INDEX);
94+
}
95+
96+
public HoodieTableMetadata getMetadataTable() {
97+
// initialize the metadata table lazily
98+
if (this.metadataTable == null) {
99+
this.metadataTable = metaClient.getTableFormat().getMetadataFactory().create(
100+
HoodieFlinkEngineContext.DEFAULT,
101+
metaClient.getStorage(),
102+
StreamerUtil.metadataConfig(conf),
103+
basePath);
104+
}
105+
return this.metadataTable;
106+
}
107+
108+
public List<FileSlice> computeCandidateFileSlices(List<FileSlice> fileSlices) {
109+
if (!isIndexAvailable()) {
110+
return fileSlices;
111+
}
112+
113+
try {
114+
Option<Set<HoodieFileGroupId>> candidateFileGroupIds =
115+
lookupCandidateFileGroupIds(fileSlices);
116+
return candidateFileGroupIds.map(candidates -> fileSlices.stream()
117+
.filter(fileSlice -> candidates.contains(fileSlice.getFileGroupId()))
118+
.collect(Collectors.toList()))
119+
.orElse(fileSlices);
120+
} catch (Throwable e) {
121+
log.error("Failed to read metadata index: {} for data skipping", getIndexPartitionName(), e);
122+
return fileSlices;
123+
}
124+
}
125+
126+
protected abstract Option<Set<HoodieFileGroupId>> lookupCandidateFileGroupIds(List<FileSlice> fileSlices);
127+
128+
protected static Set<HoodieFileGroupId> getFileGroupIds(
129+
HoodiePairData<String, HoodieRecordGlobalLocation> recordIndexData) {
130+
List<Pair<String, HoodieRecordGlobalLocation>> recordIndexLocations =
131+
HoodieDataUtils.dedupeAndCollectAsList(recordIndexData);
132+
return recordIndexLocations.stream()
133+
.map(pair -> new HoodieFileGroupId(pair.getValue().getPartitionPath(), pair.getValue().getFileId()))
134+
.collect(Collectors.toSet());
135+
}
136+
137+
public static Option<BaseRecordLevelIndex> create(
138+
String basePath,
139+
Configuration conf,
140+
HoodieTableMetaClient metaClient,
141+
List<ExpressionEvaluators.Evaluator> evaluators,
142+
RowType rowType) {
143+
if (evaluators.isEmpty() || !FlinkOptions.QUERY_TYPE_SNAPSHOT.equalsIgnoreCase(conf.get(FlinkOptions.QUERY_TYPE))) {
144+
return Option.empty();
145+
}
146+
if (metaClient == null) {
147+
metaClient = StreamerUtil.createMetaClient(conf);
148+
}
149+
// disallow RLI for new encoding with complex key gen when the table version is lower than NINE.
150+
if (KeyGenUtils.mayUseNewEncodingForComplexKeyGen(metaClient.getTableConfig())) {
151+
return Option.empty();
152+
}
153+
154+
String[] recordKeyFields = metaClient.getTableConfig().getRecordKeyFields().orElse(new String[0]);
155+
if (recordKeyFields.length == 0) {
156+
log.warn("The table do not have record keys, skipping the rli pruning.");
157+
return Option.empty();
158+
}
159+
boolean consistentLogicalTimestampEnabled = OptionsResolver.isConsistentLogicalTimestampEnabled(conf);
160+
List<String> hoodieKeysFromFilter = computeHoodieKeyFromFilters(conf, metaClient, evaluators, recordKeyFields, rowType, consistentLogicalTimestampEnabled);
161+
if (hoodieKeysFromFilter.isEmpty()) {
162+
log.warn("The number of keys from query predicate is empty, skipping the rli pruning.");
163+
return Option.empty();
164+
}
165+
int maxKeyNum = conf.get(FlinkOptions.READ_DATA_SKIPPING_RLI_KEYS_MAX_NUM);
166+
if (hoodieKeysFromFilter.size() > maxKeyNum) {
167+
log.warn("The number of keys from query predicate: {} exceeds the upper threshold: {}, skipping the rli pruning, the keys: {}",
168+
hoodieKeysFromFilter.size(), maxKeyNum, hoodieKeysFromFilter);
169+
return Option.empty();
170+
}
171+
HoodieIndexDefinition indexDefinition = metaClient.getIndexForMetadataPartition(HoodieTableMetadataUtil.PARTITION_NAME_RECORD_INDEX).orElse(null);
172+
if (indexDefinition == null) {
173+
return Option.empty();
174+
}
175+
return Option.of(HoodieRecordIndex.isPartitioned(indexDefinition)
176+
? new RecordLevelIndex(basePath, conf, metaClient, hoodieKeysFromFilter)
177+
: new GlobalRecordLevelIndex(basePath, conf, metaClient, hoodieKeysFromFilter));
178+
}
179+
180+
/**
181+
* Given query filters, it filters the EqualTo, IN and OR queries on record key columns and
182+
* returns the list of record key literals present in the query, for example:
183+
* <p>
184+
* filter1: `key1` = 'val1', returns {"val1"}
185+
* filter2: `key1` in ('val1', 'val2', 'val3'), returns {"val1", "vale", "val3"}
186+
* filter3: `key1` = 'val1' OR `key1` = 'val2' or `key1` = 'val3', returns {"val1", "vale", "val3"}
187+
* filter4: `key1` = 'val1' AND `key2` in ('val2', 'val3'), returns {"key1:val1,key2:val2", "key1:val1,key2:val3"}
188+
*/
189+
@VisibleForTesting
190+
public static List<String> computeHoodieKeyFromFilters(
191+
Configuration conf,
192+
HoodieTableMetaClient metaClient,
193+
List<ExpressionEvaluators.Evaluator> evaluators,
194+
String[] keyFields,
195+
RowType rowType,
196+
boolean consistentLogicalTimestampEnabled) {
197+
String[] partitionFields = metaClient.getTableConfig().getPartitionFields().orElse(new String[0]);
198+
// align with the check logic in RowDataKeyGen
199+
boolean isComplexRecordKey = keyFields.length > 1 || partitionFields.length > 1 && !OptionsResolver.useComplexKeygenNewEncoding(conf);
200+
List<String> hoodieKeys = new ArrayList<>();
201+
List<String> fieldNames = rowType.getFieldNames();
202+
for (String keyField: keyFields) {
203+
List<String> recordKeys = new ArrayList<>();
204+
LogicalType fieldType = rowType.getTypeAt(fieldNames.indexOf(keyField));
205+
for (ExpressionEvaluators.Evaluator evaluator: evaluators) {
206+
// if there exists multiple ref fields in an evaluator, ignore this evaluator, e.g., key = 'key1' or age = 20
207+
List<Object> literals = collectLiterals(evaluator, keyField);
208+
literals.forEach(val -> recordKeys.add(isComplexRecordKey
209+
? keyField + KeyGenerator.DEFAULT_COLUMN_VALUE_SEPARATOR + normalizeLiteral(val, keyField, fieldType, consistentLogicalTimestampEnabled)
210+
: normalizeLiteral(val, keyField, fieldType, consistentLogicalTimestampEnabled)));
211+
}
212+
if (recordKeys.isEmpty()) {
213+
log.info("No literals found for the record key: {}, therefore filtering can not be performed", keyField);
214+
return Collections.emptyList();
215+
} else if (!isComplexRecordKey || hoodieKeys.isEmpty()) {
216+
hoodieKeys = recordKeys;
217+
} else {
218+
// Combine literals for this configured record key with literals for the other configured record keys
219+
// If there are two literals for rk1, rk2, rk3 each. A total of 8 combinations will be generated
220+
List<String> tmpHoodieKeys = new ArrayList<>();
221+
for (String compositeKey: hoodieKeys) {
222+
for (String recordKey: recordKeys) {
223+
tmpHoodieKeys.add(compositeKey + KeyGenerator.DEFAULT_RECORD_KEY_PARTS_SEPARATOR + recordKey);
224+
}
225+
}
226+
hoodieKeys = tmpHoodieKeys;
227+
}
228+
}
229+
return hoodieKeys;
230+
}
231+
232+
/**
233+
* Collect literal values for record key fields from the predicate.
234+
*/
235+
private static List<Object> collectLiterals(ExpressionEvaluators.Evaluator evaluator, String refName) {
236+
if (evaluator instanceof ExpressionEvaluators.LeafEvaluator
237+
&& !((ExpressionEvaluators.LeafEvaluator) evaluator).getName().equalsIgnoreCase(refName)) {
238+
return Collections.emptyList();
239+
}
240+
if (evaluator instanceof ExpressionEvaluators.EqualTo) {
241+
Object valueLiteral = ((ExpressionEvaluators.EqualTo) evaluator).getVal();
242+
return valueLiteral == null ? Collections.emptyList() : Collections.singletonList(valueLiteral);
243+
} else if (evaluator instanceof ExpressionEvaluators.In) {
244+
Object[] valueLiterals = ((ExpressionEvaluators.In) evaluator).getVals();
245+
if (valueLiterals.length < 1 || Arrays.stream(valueLiterals).anyMatch(Objects::isNull)) {
246+
return Collections.emptyList();
247+
}
248+
return Arrays.stream(valueLiterals).collect(Collectors.toList());
249+
} else if (evaluator instanceof ExpressionEvaluators.Or) {
250+
List<List<Object>> literalsList = Arrays.stream(((ExpressionEvaluators.Or) evaluator).getEvaluators())
251+
.map(eval -> collectLiterals(eval, refName)).collect(Collectors.toList());
252+
// if any child expr do not contain predicate on the key, just return empty list
253+
if (literalsList.stream().anyMatch(List::isEmpty)) {
254+
return Collections.emptyList();
255+
}
256+
return literalsList.stream().flatMap(List::stream).distinct().collect(Collectors.toList());
257+
} else {
258+
return Collections.emptyList();
259+
}
260+
}
261+
262+
/**
263+
* Normalize literal values before used to get record index locations.
264+
*/
265+
private static String normalizeLiteral(Object value, String keyField, LogicalType fieldType, boolean consistentLogicalTimestampEnabled) {
266+
switch (fieldType.getTypeRoot()) {
267+
case DECIMAL:
268+
// the scale of decimal data in predicate may not be aligned with that in record index, padding 0 if necessary,
269+
// e.g., 1.11 with target scale 5, return 1.11000
270+
BigDecimal decimal = (BigDecimal) value;
271+
int targetScale = ((DecimalType) fieldType).getScale();
272+
value = decimal.scale() >= targetScale ? value : decimal.setScale(targetScale);
273+
break;
274+
case TIMESTAMP_WITHOUT_TIME_ZONE:
275+
// the original value is extracted from literal by ExpressionUtils#getValueFromLiteral, which is epoch millis
276+
// convert it back to TimestampData before reusing key generating logic in RowDataKeyGen.
277+
value = TimestampData.fromEpochMillis((Long) value);
278+
break;
279+
default:
280+
break;
281+
}
282+
// to align with the hoodie key generating logic in writer side.
283+
return RowDataKeyGen.getRecordKey(value, keyField, consistentLogicalTimestampEnabled);
284+
}
285+
286+
@Override
287+
public void close() {
288+
if (this.metadataTable == null) {
289+
return;
290+
}
291+
try {
292+
this.metadataTable.close();
293+
} catch (Exception e) {
294+
throw new HoodieException("Exception happened during close metadata table.", e);
295+
}
296+
}
297+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package org.apache.hudi.source.stats;
20+
21+
import org.apache.hudi.common.data.HoodieListData;
22+
import org.apache.hudi.common.data.HoodiePairData;
23+
import org.apache.hudi.common.model.FileSlice;
24+
import org.apache.hudi.common.model.HoodieFileGroupId;
25+
import org.apache.hudi.common.model.HoodieRecordGlobalLocation;
26+
import org.apache.hudi.common.table.HoodieTableMetaClient;
27+
import org.apache.hudi.common.util.Option;
28+
29+
import org.apache.flink.configuration.Configuration;
30+
31+
import java.util.List;
32+
import java.util.Set;
33+
34+
/**
35+
* An index support implementation that leverages global Record Level Index to prune file slices.
36+
*/
37+
public class GlobalRecordLevelIndex extends BaseRecordLevelIndex {
38+
private static final long serialVersionUID = 1L;
39+
40+
GlobalRecordLevelIndex(
41+
String basePath,
42+
Configuration conf,
43+
HoodieTableMetaClient metaClient,
44+
List<String> hoodieKeysFromFilter) {
45+
super(basePath, conf, metaClient, hoodieKeysFromFilter);
46+
}
47+
48+
@Override
49+
protected Option<Set<HoodieFileGroupId>> lookupCandidateFileGroupIds(List<FileSlice> fileSlices) {
50+
HoodiePairData<String, HoodieRecordGlobalLocation> recordIndexData =
51+
getMetadataTable().readRecordIndexLocationsWithKeys(HoodieListData.eager(hoodieKeysFromFilter));
52+
return Option.of(getFileGroupIds(recordIndexData));
53+
}
54+
}

0 commit comments

Comments
 (0)