wombatu-kun commented on code in PR #19288:
URL: https://github.com/apache/hudi/pull/19288#discussion_r3658583976
##########
hudi-trino/src/main/java/io/trino/plugin/hudi/HudiUtil.java:
##########
@@ -348,6 +365,115 @@ public static Schema.Field getFieldFromSchema(String
columnName, Schema schema)
"Failed to get column " + columnName + " from table schema");
}
+ /**
+ * Builds a {@link HiveColumnHandle} for a table-schema field, typing it
from the field's Avro schema.
+ * Used to resolve columns the file-group reader requires for merging but
the connector projection does
+ * not carry (e.g. {@code _hoodie_commit_time} or a delete-marker column
on a narrow query).
+ * <p>
+ * The handle's {@code hiveColumnIndex} is a placeholder: page sources
built from these handles resolve
+ * parquet columns by NAME (directly when {@code
hudi.parquet.use-column-names=true}, otherwise
+ * {@link HudiPageSourceProvider#remapColumnIndicesToPhysical} rebuilds
every index from the file
+ * schema by name), so the ordinal is never used to locate data.
+ * <p>
+ * Avro types whose Trino mapping has no Hive counterpart (uuid,
time-millis/micros) fail with
+ * NOT_SUPPORTED from {@link HiveTypeTranslator#toHiveType}; such columns
are equally unreadable
+ * through the metastore schema, so this surfaces the same limitation with
a clear error.
+ */
+ public static HiveColumnHandle toColumnHandle(HoodieSchemaField field)
+ {
+ Type trinoType;
+ try {
+ trinoType =
AVRO_TYPE_HANDLER.typeFor(field.schema().toAvroSchema());
+ }
+ catch (AvroTypeException e) {
+ throw new TrinoException(HUDI_SCHEMA_ERROR,
+ "Failed to map Avro type of column " + field.name() + " to
a Trino type", e);
+ }
+ return new HiveColumnHandle(
+ field.name(),
+ 0,
+ HiveTypeTranslator.toHiveType(trinoType),
+ trinoType,
+ Optional.empty(),
+ HiveColumnHandle.ColumnType.REGULAR,
+ Optional.empty());
+ }
+
+ /**
+ * Resolves the merge mode and merge strategy id the file-group reader
will use for this table,
+ * applying hudi-common's version-gated inference: the merge MODE is
inferred for any table below
+ * version 9 ({@code
FileGroupReaderSchemaHandler.generateRequiredSchema}), while the STRATEGY ID
+ * the merger is resolved with is inferred only below version 8
+ * ({@code HoodieReaderContext.initRecordMerger}). The asymmetry is
deliberate and must mirror
+ * hudi-common exactly: pre-v9 tables may persist neither config (a 0.x
table with a custom payload
+ * class infers CUSTOM mode with the payload-based strategy id), so a
connector-side decision built
+ * on the raw configs would disagree with the file-group reader's.
+ */
+ public static Pair<RecordMergeMode, String>
resolveMergeModeAndStrategyId(HoodieTableConfig tableConfig)
+ {
+ RecordMergeMode mergeMode = tableConfig.getRecordMergeMode();
+ String mergeStrategyId = tableConfig.getRecordMergeStrategyId();
+ if (tableConfig.getTableVersion().lesserThan(HoodieTableVersion.NINE))
{
+ Triple<RecordMergeMode, String, String> inferred =
HoodieTableConfig.inferMergingConfigsForPreV9Table(
+ tableConfig.getRecordMergeMode(),
+ tableConfig.getPayloadClass(),
+ tableConfig.getRecordMergeStrategyId(),
+ tableConfig.getOrderingFieldsStr().orElse(null),
+ tableConfig.getTableVersion());
+ mergeMode = inferred.getLeft();
+ if
(tableConfig.getTableVersion().lesserThan(HoodieTableVersion.EIGHT)) {
+ mergeStrategyId = inferred.getRight();
+ }
+ }
+ return Pair.of(mergeMode, mergeStrategyId);
+ }
+
+ /**
+ * Returns whether reads of this table go through a CUSTOM record merger
that is NOT projection
+ * compatible. For such mergers the file-group reader demands the FULL
table schema as its required
+ * schema on any split with log files ({@code
FileGroupReaderSchemaHandler.generateRequiredSchema}),
+ * so the connector must read every table column, not just the query
projection.
+ * <p>
+ * Callers must pass the merge mode and strategy id resolved by {@link
#resolveMergeModeAndStrategyId}
+ * so the {@link HoodieRecordUtils#createValidRecordMerger} call here sees
the same inputs as the
+ * file-group reader's own resolution. A CUSTOM mode without a strategy id
returns false: no merger
+ * can be resolved from it, and the file-group reader itself then fails
loudly.
Review Comment:
For a null strategy id the file-group reader does not fail loudly -
`createValidRecordMerger` dereferences the id on its first line and NPEs, which
is the v8-plus-custom-payload case the new unit test pins. Either drop the
"fails loudly" half or raise the actionable error here.
##########
hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java:
##########
@@ -425,10 +488,14 @@ public static List<HiveColumnHandle>
remapColumnIndicesToPhysical(
String requestedName = originalHandle.getBaseColumnName();
// Determine the key to use for looking up the physical index
- String lookupKey = caseSensitive ? requestedName :
requestedName.toLowerCase(Locale.getDefault());
+ String lookupKey = caseSensitive ? requestedName :
requestedName.toLowerCase(Locale.ROOT);
- // Find the physical index from the file schema map constructed
from fielSchema
+ // Find the physical index from the file schema map constructed
from fileSchema
Integer physicalIndex = physicalIndexMap.get(lookupKey);
+ if (physicalIndex == null) {
+ throw new TrinoException(HUDI_SCHEMA_ERROR, format(
+ "Column '%s' not found in parquet file schema %s",
requestedName, fileSchema.getName()));
Review Comment:
With the projection now widened to the full table schema, this throw makes
`hudi.parquet.use-column-names=false` fail on any base file written before a
column was added, while the default name-based path null-fills it in
`createParquetPageSource`. Mapping a not-found column to `fileFields.size()`
would fall into the parquet reader's own absent-column handling instead - or is
the hard failure deliberate here?
##########
hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java:
##########
@@ -260,17 +281,59 @@ public ConnectorPageSource createPageSource(
.withDataSchema(dataSchema)
.withRequestedSchema(HoodieSchema.fromAvroSchema(requestedSchema))
.withLatestCommitTime(hudiTableHandle.getLatestCommitTime())
- .withProps(buildReaderProperties(session, metaClient))
+ .withProps(readerProps)
.withShouldUseRecordPosition(false)
.withStart(start)
.withLength(length)
.build();
return new HudiPageSource(
dataPageSource,
fileGroupReader,
- readerContext,
hiveColumnHandles,
- synthesizedColumnHandler);
+ prefilledColumnValues);
+ }
+
+ private ConnectorPageSource createBaseFilePageSource(
+ ConnectorSession session,
+ List<HiveColumnHandle> columns,
+ HudiSplit hudiSplit,
+ TrinoFileSystem fileSystem,
+ ParquetReaderOptions sessionOptions,
+ long start,
+ long length,
+ DynamicFilter dynamicFilter,
+ boolean enablePredicatePushDown)
+ {
+ HudiBaseFile baseFile = hudiSplit.getBaseFile().orElseThrow();
+ return createPageSource(
+ session,
+ columns,
+ hudiSplit,
+ fileSystem.newInputFile(Location.of(baseFile.getPath()),
baseFile.getFileSize()),
+ baseFile.getPath(),
+ start,
+ length,
+ OptionalLong.of(baseFile.getFileSize()),
+ dataSourceStats,
+ sessionOptions,
+ timeZone,
+ dynamicFilter,
+ enablePredicatePushDown);
+ }
+
+ /**
+ * Mirrors {@code FileGroupReaderSchemaHandler.generateRequiredSchema}'s
decision for CUSTOM merge
+ * mode: resolves the merge mode and strategy id with the version-gated
inference the file-group
+ * reader applies ({@link HudiUtil#resolveMergeModeAndStrategyId}) and
asks the same resolved merger
+ * whether it is projection compatible, so the connector's read projection
and the file-group
+ * reader's required schema cannot disagree.
Review Comment:
This says the two "cannot disagree", but only the CUSTOM and
`isProjectionCompatible` half is mirrored exactly - the mandatory-fields half
is a superset, and the guard this PR adds to `getFileRecordIterator` exists for
exactly that drift. Worth softening to what the method actually guarantees.
##########
hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiNonProjectionCompatibleMerger.java:
##########
@@ -0,0 +1,89 @@
+/*
+ * 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 io.trino.plugin.hudi;
+
+import com.google.common.collect.ImmutableMap;
+import
io.trino.plugin.hudi.testing.NonProjectionCompatibleMergerHudiTablesInitializer;
+import io.trino.plugin.hudi.testing.NonProjectionCompatibleRankMerger;
+import io.trino.testing.AbstractTestQueryFramework;
+import io.trino.testing.QueryRunner;
+import org.junit.jupiter.api.Test;
+
+import static
io.trino.plugin.hudi.testing.NonProjectionCompatibleMergerHudiTablesInitializer.RT_TABLE_NAME;
+import static
io.trino.plugin.hudi.testing.NonProjectionCompatibleMergerHudiTablesInitializer.TABLE_NAME;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Acceptance test for full-table-schema merge reads (apache/hudi#19249, issue
comment on scope):
+ * {@link NonProjectionCompatibleRankMerger} does NOT override {@code
isProjectionCompatible()} (default
+ * {@code false}) and does NOT declare {@code merge_rank} mandatory, so the
file-group reader demands the
+ * FULL table schema as its required schema for base and log reads alike, and
nothing prepends
+ * {@code merge_rank} into the connector's read projection.
+ * <p>
+ * The queries below never project {@code merge_rank}. The data is laid out so
each merge direction is
+ * proven independently: {@code k1}'s winning rank is on the LOG record
(update wins, value 99) and
+ * {@code k2}'s winning rank is on the BASE record (base wins, value 100). A
correct result therefore
+ * requires the un-projected rank column to be read on BOTH sides of the
merge. {@code sum(value)} is a
+ * three-way discriminator: merged = 199, base-only = 110, built-in
newest-wins = 103.
+ */
+public class TestHudiNonProjectionCompatibleMerger
Review Comment:
Every pre-v9 payload-class table now takes the full-table-schema read path,
but nothing exercises it end to end - the v6 and v8 MOR fixtures all infer
COMMIT_TIME or EVENT_TIME, and both CUSTOM initializers set an explicit
strategy id and merger impl. Worth one table configured with only a payload
class, so the payload-based strategy actually gets read through.
##########
hudi-trino/src/test/java/io/trino/plugin/hudi/testing/NonProjectionCompatibleMergerHudiTablesInitializer.java:
##########
@@ -0,0 +1,242 @@
+/*
+ * 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 io.trino.plugin.hudi.testing;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import io.trino.filesystem.Location;
+import io.trino.filesystem.TrinoFileSystem;
+import io.trino.filesystem.TrinoFileSystemFactory;
+import io.trino.metastore.Column;
+import io.trino.metastore.HiveMetastore;
+import io.trino.metastore.HiveMetastoreFactory;
+import io.trino.metastore.PrincipalPrivileges;
+import io.trino.metastore.StorageFormat;
+import io.trino.metastore.Table;
+import io.trino.plugin.hudi.HudiConnector;
+import io.trino.spi.security.ConnectorIdentity;
+import io.trino.testing.QueryRunner;
+import org.apache.avro.Schema;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.Path;
+import org.apache.hudi.client.HoodieJavaWriteClient;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.client.common.HoodieJavaEngineContext;
+import org.apache.hudi.common.bootstrap.index.NoOpBootstrapIndex;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.config.RecordMergeMode;
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieAvroRecord;
+import org.apache.hudi.common.model.HoodieKey;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.marker.MarkerType;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieCompactionConfig;
+import org.apache.hudi.config.HoodieIndexConfig;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.index.HoodieIndex;
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static com.google.common.io.MoreFiles.deleteRecursively;
+import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
+import static io.trino.hive.formats.HiveClassNames.HUDI_PARQUET_INPUT_FORMAT;
+import static
io.trino.hive.formats.HiveClassNames.HUDI_PARQUET_REALTIME_INPUT_FORMAT;
+import static
io.trino.hive.formats.HiveClassNames.MAPRED_PARQUET_OUTPUT_FORMAT_CLASS;
+import static io.trino.hive.formats.HiveClassNames.PARQUET_HIVE_SERDE_CLASS;
+import static io.trino.metastore.HiveType.HIVE_LONG;
+import static io.trino.metastore.HiveType.HIVE_STRING;
+import static io.trino.plugin.hive.TableType.EXTERNAL_TABLE;
+import static java.nio.file.Files.createTempDirectory;
+
+/**
+ * Creates a non-partitioned Merge-On-Read table at test runtime configured
with the
+ * NON-projection-compatible {@link NonProjectionCompatibleRankMerger} via
{@link RecordMergeMode#CUSTOM}.
+ * One {@code bulkInsert} (base files) is followed by one {@code upsert} of
the same keys (log files), with
+ * inline compaction disabled so the merger runs at read time over a
full-table-schema read.
+ * <p>
+ * Data is laid out so each merge direction is distinguishable: one key's
winning rank comes from the LOG
+ * record and the other's from the BASE record, so a correct result proves
both sides of the merge supplied
+ * the (never projected) {@code merge_rank} column. See {@code
TestHudiNonProjectionCompatibleMerger}.
+ */
+public class NonProjectionCompatibleMergerHudiTablesInitializer
Review Comment:
This is the third near-identical MoR initializer - `initializeTables`,
`createTableDefinition`, the `_hoodie_*` column list and `createWriteClient`
are verbatim copies of `CustomMergerHudiTablesInitializer` apart from names and
the merger. Worth a shared base taking (tableName, mergerClass, strategyId,
fields, records); follow-up, not a blocker.
##########
hudi-trino/src/main/java/io/trino/plugin/hudi/HudiUtil.java:
##########
@@ -471,12 +602,38 @@ public static List<HiveColumnHandle>
getMergeRequiredColumnHandles(
}
}
- if (requiredColumnNames.isEmpty()) {
- return Collections.emptyList();
- }
return buildColumnHandles(table, typeManager, requiredColumnNames,
timestampPrecision);
}
+ /**
+ * The merge-mandatory column names that do not depend on a custom merger,
mirroring the non-CUSTOM
+ * branch of {@code
FileGroupReaderSchemaHandler.getMandatoryFieldsForMerging}. The delete-marker
and
+ * operation fields are added unconditionally: {@code buildColumnHandles}
drops any name that is not a
+ * data column of the table, so tables without them read nothing extra.
+ */
+ @VisibleForTesting
+ static LinkedHashSet<String> mergeRequiredColumnNames(HoodieTableConfig
tableConfig, RecordMergeMode recordMergeMode)
+ {
+ LinkedHashSet<String> requiredColumnNames = new LinkedHashSet<>();
+ if (recordMergeMode != null && recordMergeMode !=
RecordMergeMode.COMMIT_TIME_ORDERING) {
+ requiredColumnNames.addAll(tableConfig.getOrderingFields());
+ }
+ // Without populated meta fields the file-group reader keys records on
the record-key data columns
+ if (!tableConfig.populateMetaFields()) {
+ tableConfig.getRecordKeyFields().ifPresent(fields ->
requiredColumnNames.addAll(Arrays.asList(fields)));
+ }
+ // Delete markers and the operation field decide record deletion at
merge time
+ requiredColumnNames.add(HOODIE_IS_DELETED_FIELD);
Review Comment:
These two are filtered against the metastore data columns by
`buildColumnHandles`, while `getMandatoryFieldsForMerging` and `DeleteContext`
gate them on the Avro table schema - a table whose metastore omits a field the
schema has (hive-sync with `omit_metadata_fields=true` drops
`_hoodie_operation`, a hand-written external DDL drops `_hoodie_is_deleted`)
loses it from the base projection and the new guard then fails the read. Could
these resolve off the table schema via `toColumnHandle`, the way the CUSTOM
branch already does?
##########
hudi-trino/src/main/java/io/trino/plugin/hudi/util/PrefilledColumnValues.java:
##########
@@ -0,0 +1,126 @@
+/*
+ * 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 io.trino.plugin.hudi.util;
+
+import com.google.common.collect.ImmutableMap;
+import io.trino.plugin.hive.HiveColumnHandle;
+import io.trino.plugin.hive.HivePartitionKey;
+import io.trino.plugin.hudi.HudiSplit;
+import io.trino.plugin.hudi.file.HudiFile;
+import io.trino.spi.block.Block;
+import io.trino.spi.block.BlockBuilder;
+import io.trino.spi.block.RunLengthEncodedBlock;
+
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalInt;
+
+import static io.trino.metastore.Partitions.makePartName;
+import static
io.trino.plugin.hive.HiveColumnHandle.isFileModifiedTimeColumnHandle;
+import static io.trino.plugin.hive.HiveColumnHandle.isFileSizeColumnHandle;
+import static io.trino.plugin.hive.HiveColumnHandle.isPartitionColumnHandle;
+import static io.trino.plugin.hive.HiveColumnHandle.isPathColumnHandle;
+import static io.trino.plugin.hive.util.HiveUtil.getPrefilledColumnValue;
+import static io.trino.spi.type.TypeUtils.writeNativeValue;
+
+/**
+ * Per-split constant values for the output columns that are not stored in the
data file: Hive-style
+ * partition columns and Trino's hidden metadata columns ({@code $path},
{@code $file_size},
+ * {@code $file_modified_time}, {@code $partition}). Value computation
delegates to
+ * {@link io.trino.plugin.hive.util.HiveUtil#getPrefilledColumnValue}, the
same implementation the Hive
+ * connector uses for these columns (including the {@code "\N"} hive-null
partition-value convention).
+ */
+public class PrefilledColumnValues
+{
+ private final Map<String, HivePartitionKey> partitionKeysByName;
+ private final String partitionName;
+ private final String filePath;
+ private final long fileSize;
+ private final long fileModifiedTime;
+
+ public static PrefilledColumnValues create(HudiSplit hudiSplit)
+ {
+ return new PrefilledColumnValues(hudiSplit);
+ }
+
+ private PrefilledColumnValues(HudiSplit hudiSplit)
+ {
+ List<HivePartitionKey> partitionKeys = hudiSplit.getPartitionKeys();
+ // ImmutableMap preserves insertion order, so $partition renders the
keys in the split's
+ // partition-column order.
+ ImmutableMap.Builder<String, HivePartitionKey> byName =
ImmutableMap.builder();
+ partitionKeys.forEach(partitionKey -> byName.put(partitionKey.name(),
partitionKey));
+ this.partitionKeysByName = byName.buildOrThrow();
+ this.partitionName = makePartName(
+ partitionKeys.stream().map(HivePartitionKey::name).toList(),
+ partitionKeys.stream().map(HivePartitionKey::value).toList());
+ // Parquet files will be prioritised over log files
+ HudiFile hudiFile = hudiSplit.getBaseFile().isPresent()
+ ? hudiSplit.getBaseFile().get()
+ : hudiSplit.getLogFiles().getFirst();
+ this.filePath = hudiFile.getPath();
+ this.fileSize = hudiFile.getFileSize();
+ this.fileModifiedTime = hudiFile.getModificationTime();
+ }
+
+ /**
+ * Returns whether this split can provide a value for the column, i.e. it
is a partition column of
+ * the split or a hidden metadata column Hudi populates.
+ */
+ public boolean isPrefilled(HiveColumnHandle columnHandle)
+ {
+ return partitionKeysByName.containsKey(columnHandle.getName())
+ || isPathColumnHandle(columnHandle)
+ || isFileSizeColumnHandle(columnHandle)
+ || isFileModifiedTimeColumnHandle(columnHandle)
+ || isPartitionColumnHandle(columnHandle);
+ }
+
+ /**
+ * Appends the column's value for this split to the builder; appends null
for a column this split
+ * cannot provide.
+ */
+ public void appendTo(HiveColumnHandle columnHandle, BlockBuilder
blockBuilder)
+ {
+ writeNativeValue(columnHandle.getType(), blockBuilder,
nativeValueOf(columnHandle));
+ }
+
+ /**
+ * Builds a run-length-encoded {@link Block} repeating the column's
constant value for
+ * {@code positionCount} positions (null-filled for a column this split
cannot provide).
+ */
+ public Block toRleBlock(HiveColumnHandle columnHandle, int positionCount)
+ {
+ return RunLengthEncodedBlock.create(columnHandle.getType(),
nativeValueOf(columnHandle), positionCount);
+ }
+
+ private Object nativeValueOf(HiveColumnHandle columnHandle)
+ {
+ if (!isPrefilled(columnHandle)) {
+ // Lenient null fill, e.g. for a hidden column Trino defines but
Hudi does not populate.
+ return null;
+ }
+ HivePartitionKey partitionKey =
partitionKeysByName.get(columnHandle.getName());
+ return getPrefilledColumnValue(
Review Comment:
The swap also moves `$file_modified_time` from a UTC-pinned zone to the
worker's default zone - `getPrefilledColumnValue` packs with
`DateTimeZone.getDefault()` where the deleted code used `UTC_KEY`. Same
instant, but the rendered zone and zone-sensitive functions change, so it
belongs in the delta list with the other three - or was UTC the accident?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]