This is an automated email from the ASF dual-hosted git repository.
voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new 1593524e3244 fix(hadoop-mr): ignore blank Hive projection ids and
report both lists on mismatch (#19463)
1593524e3244 is described below
commit 1593524e3244a7718fe5af1d75a19c703f71933a
Author: Ranga Reddy <[email protected]>
AuthorDate: Sun Aug 30 19:59:47 2026 +0530
fix(hadoop-mr): ignore blank Hive projection ids and report both lists on
mismatch (#19463)
Relates to #14673 (HUDI-1286).
For SELECT COUNT(*) on Hive before 3.0.0 the read-column ids arrive
empty and Hive combines them with Hudi's required projection ids into
e.g. ",2,0,3" (HIVE-22438). Every consumer parses those with
Integer.parseInt, so a blank entry failed with a bare
NumberFormatException carrying none of the projection lists.
cleanProjectionColumnIds is the single point all of them read from, so
the filter goes there. It stripped one leading comma, which leaves
",,2,0" and cannot reach an interior blank at all: Hive prepends ids
while appending names, so an id prepended after an empty one gives
"3,,2,0". It now trims and drops every blank entry, writes the joined
value back only when it changed, and no longer NPEs when the key is
unset. The read-modify-write is synchronized on the conf, the way
addProjectionToJobConf guards its own writes; f41539a9cb5f (#3630)
moved this call out of that latch without restoring a lock.
HoodieParquetInputFormat#getRecordReader now cleans the conf as well.
That is where the bootstrap reader and the parquet schema-evolution
reader are built, and neither goes through a realtime input format, so
on COW those consumers were still reached with the blank in place.
SchemaEvolutionContext read the same key with no default and no filter
in setColumnNameList and setColumnTypeList, so an unset key was an NPE
and a blank id a bare NumberFormatException, in methods that already
report a size mismatch. Both now parse the non-blank tokens and report
that mismatch instead, carrying the counts actually compared rather
than only the raw conf value. getRequireColumn, which runs just before
them, gets the same missing-default fix.
orderFields keeps a filter of its own as defence in depth, for callers
that assemble the csv without going through the conf. Its mismatch
message compared de-duplicated counts while printing the raw name
count, so duplicate names produced two equal numbers; the counts are
now named #distinctFieldNames and #distinctFieldPositions and both
projection lists are included.
Not addressed here and filed as #19506: names and ids are de-duplicated
independently and then paired positionally, which is unsound because
Hive prepends ids while appending names. The fullColNamelist.get(id)
IOOBE in setColumnNameList is out of scope for the same reason.
The mechanism behind #14673 is unconfirmed. An earlier revision
asserted CombineHiveInputFormat accumulating projection entries across
splits, which the issue does not support: the reported frame is
HiveInputFormat.getRecordReader, the base class, and addProjectionField
always sets both keys together. HIVE-22438 and HUDI-313 are what this
repo documents for that query.
Tests: new coverage for orderFields, cleanProjectionColumnIds and
SchemaEvolutionContext, none of which had any before; a blank-id arm on
TestHoodieRealtimeRecordReader#testIncrementalWithReplace driving
cleanProjectionColumnIds and orderFields together; and blank ids in
TestHiveTableSchemaEvolution, the only test that reaches
HoodieParquetInputFormat#getRecordReader on the COW schema-evolution
path. The bootstrap arm remains uncovered, since no test constructs a
BootstrapBaseFileSplit.
---------
Co-authored-by: voon <[email protected]>
---
.../hudi/hadoop/HoodieParquetInputFormat.java | 5 +
.../apache/hudi/hadoop/SchemaEvolutionContext.java | 34 ++++-
.../utils/HoodieRealtimeInputFormatUtils.java | 47 +++++--
.../utils/HoodieRealtimeRecordReaderUtils.java | 16 ++-
.../hudi/hadoop/TestSchemaEvolutionContext.java | 94 +++++++++++++
.../realtime/TestHoodieRealtimeRecordReader.java | 19 ++-
.../utils/TestHoodieRealtimeInputFormatUtils.java | 59 +++++++++
.../utils/TestHoodieRealtimeRecordReaderUtils.java | 146 +++++++++++++++++++++
.../functional/TestHiveTableSchemaEvolution.java | 4 +-
9 files changed, 404 insertions(+), 20 deletions(-)
diff --git
a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/HoodieParquetInputFormat.java
b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/HoodieParquetInputFormat.java
index c8089ce390e1..5c6d7451a9e2 100644
---
a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/HoodieParquetInputFormat.java
+++
b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/HoodieParquetInputFormat.java
@@ -148,6 +148,11 @@ public class HoodieParquetInputFormat extends
HoodieParquetInputFormatBase {
public RecordReader<NullWritable, ArrayWritable> getRecordReader(final
InputSplit split, final JobConf job,
final
Reporter reporter) throws IOException {
HoodieRealtimeInputFormatUtils.addProjectionField(job,
job.get(hive_metastoreConstants.META_TABLE_PARTITION_COLUMNS, "").split("/"));
+ // The bootstrap and schema-evolution paths below parse the read-column
ids with Integer#parseInt, so the
+ // blank ids HIVE-22438 leaves in the conf have to be dropped here too;
neither path goes through a
+ // realtime input format. The realtime formats clean the conf before
delegating here, so on that path
+ // it is this call that is the no-op.
+ HoodieRealtimeInputFormatUtils.cleanProjectionColumnIds(job);
if (shouldUseFilegroupReader(job, split)) {
try {
if (!(split instanceof FileSplit) || !checkIfHudiTable(split, job)) {
diff --git
a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/SchemaEvolutionContext.java
b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/SchemaEvolutionContext.java
index e727965587a7..d3a4532ba3c0 100644
---
a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/SchemaEvolutionContext.java
+++
b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/SchemaEvolutionContext.java
@@ -254,13 +254,35 @@ public class SchemaEvolutionContext {
}
}
+ /**
+ * Reads {@code hive.io.file.readcolumn.ids} as its non-blank, trimmed
tokens. The key is unset until
+ * something projects a column, and {@code
HoodieRealtimeInputFormatUtils#cleanProjectionColumnIds} drops
+ * the blank ids HIVE-22438 leaves in it before any reader runs; this keeps
both callers below safe if
+ * either ever reaches them anyway.
+ */
+ private static List<String> parseReadColumnIds(JobConf job) {
+ return
Arrays.stream(job.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR,
"").split(","))
+ .map(String::trim)
+ .filter(id -> !id.isEmpty())
+ .collect(Collectors.toList());
+ }
+
public void setColumnTypeList(JobConf job, List<Types.Field> fields) {
List<TypeInfo> fullTypeInfos =
TypeInfoUtils.getTypeInfosFromTypeString(job.get(serdeConstants.LIST_COLUMN_TYPES));
- List<Integer> tmpColIdList =
Arrays.stream(job.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR).split(","))
+ // Blank ids are dropped rather than parsed: HIVE-22438 puts them in this
conf value, and the key is
+ // unset until something projects a column. Either way the size check
below reports the id list instead
+ // of failing with a bare NumberFormatException or an NPE.
+ List<Integer> tmpColIdList = parseReadColumnIds(job).stream()
.map(Integer::parseInt).collect(Collectors.toList());
if (tmpColIdList.size() != fields.size()) {
- throw new HoodieException(String.format("The size of
hive.io.file.readcolumn.ids: %s is not equal to projection columns: %s",
- job.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR),
fields.stream().map(Types.Field::name).collect(Collectors.joining(","))));
+ // Report the counts that were compared, not just the raw conf value:
the ids are counted after blanks
+ // are dropped, so the string printed here can hold more entries than
the number the check used.
+ throw new HoodieException(String.format(
+ "The size of hive.io.file.readcolumn.ids: %s is not equal to
projection columns: %s. "
+ + "#nonBlankIds: %d, #projectionColumns: %d",
+ job.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR, ""),
+
fields.stream().map(Types.Field::name).collect(Collectors.joining(",")),
+ tmpColIdList.size(), fields.size()));
}
List<TypeInfo> fieldTypes = new ArrayList<>();
for (int i = 0; i < tmpColIdList.size(); i++) {
@@ -389,7 +411,7 @@ public class SchemaEvolutionContext {
if (fields == null) {
return;
}
- List<String> tmpColIdList =
Arrays.asList(job.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR).split(","));
+ List<String> tmpColIdList = parseReadColumnIds(job);
if (fields.size() != tmpColIdList.size()) {
return;
}
@@ -417,11 +439,11 @@ public class SchemaEvolutionContext {
public static List<String> getRequireColumn(JobConf jobConf) {
String originColumnString =
jobConf.get(HIVE_TMP_READ_COLUMN_NAMES_CONF_STR);
if (StringUtils.isNullOrEmpty(originColumnString)) {
- jobConf.set(HIVE_TMP_READ_COLUMN_NAMES_CONF_STR,
jobConf.get(ColumnProjectionUtils.READ_COLUMN_NAMES_CONF_STR));
+ jobConf.set(HIVE_TMP_READ_COLUMN_NAMES_CONF_STR,
jobConf.get(ColumnProjectionUtils.READ_COLUMN_NAMES_CONF_STR, ""));
}
String hoodieFullColumnString = jobConf.get(HIVE_TMP_COLUMNS);
if (StringUtils.isNullOrEmpty(hoodieFullColumnString)) {
- jobConf.set(HIVE_TMP_COLUMNS, jobConf.get(serdeConstants.LIST_COLUMNS));
+ jobConf.set(HIVE_TMP_COLUMNS, jobConf.get(serdeConstants.LIST_COLUMNS,
""));
}
String tableColumnString =
jobConf.get(HIVE_TMP_READ_COLUMN_NAMES_CONF_STR);
List<String> tableColumns = Arrays.asList(tableColumnString.split(","));
diff --git
a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieRealtimeInputFormatUtils.java
b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieRealtimeInputFormatUtils.java
index f40eca130872..791aba0d0400 100644
---
a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieRealtimeInputFormatUtils.java
+++
b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieRealtimeInputFormatUtils.java
@@ -128,16 +128,47 @@ public class HoodieRealtimeInputFormatUtils extends
HoodieInputFormatUtils {
}
/**
- * Hive will append read columns' ids to old columns' ids during
getRecordReader. In some cases, e.g. SELECT COUNT(*),
- * the read columns' id is an empty string and Hive will combine it with
Hoodie required projection ids and becomes
- * e.g. ",2,0,3" and will cause an error. Actually this method is a
temporary solution because the real bug is from
- * Hive. Hive has fixed this bug after 3.0.0, but the version before that
would still face this problem. (HIVE-22438)
+ * Drops blank entries from the read-column id list held in {@code conf}.
+ *
+ * <p>For {@code SELECT COUNT(*)} on Hive before 3.0.0 the read-column ids
arrive empty and Hive combines
+ * them into e.g. {@code ",2,0,3"} (HIVE-22438). Every consumer parses those
ids with
+ * {@code Integer#parseInt}, so a blank entry fails with a bare {@code
NumberFormatException} that carries
+ * none of the projection lists:
+ *
+ * <ul>
+ * <li>{@code SchemaEvolutionContext#setColumnNameList} and {@code
#setColumnTypeList}, both reached from
+ * {@code doEvolutionForParquetFormat}</li>
+ * <li>{@code HoodieColumnProjectionUtils#getReadColumnIDs}, on the
bootstrap path</li>
+ * <li>{@code HoodieRealtimeRecordReaderUtils#orderFields}, the path in
the reported issue</li>
+ * </ul>
+ *
+ * <p>Each of those is reached from a {@code getRecordReader} that cleans
the conf first:
+ * {@code HoodieParquetInputFormat} for the parquet and bootstrap paths, and
+ * {@code HoodieParquetRealtimeInputFormat} / {@code
HoodieHFileRealtimeInputFormat} for the realtime ones.
+ * {@code HoodieCombineHiveInputFormat} builds its per-split readers through
those same formats and hands
+ * them the conf they cleaned, so it is covered as well.
+ *
+ * <p>This is a workaround: the underlying bug is in Hive, fixed after
3.0.0, but earlier versions still
+ * hit it. Stripping a single leading comma is not enough. Hive prepends ids
while appending names, so repeated
+ * empty appends give {@code ",,2,0"}, and an id prepended after an empty
one gives {@code "3,,2,0"} where
+ * the blank is interior and no amount of leading-comma stripping reaches it.
+ *
+ * <p>Hive on Spark calls {@code getRecordReader} from several threads
sharing one {@code JobConf}, so the
+ * read-modify-write below is synchronized on the conf the same way {@code
addProjectionToJobConf} guards
+ * its own writes. The write-back is skipped when nothing changed, so an
unset key stays unset rather than
+ * being written back as empty.
*/
public static void cleanProjectionColumnIds(Configuration conf) {
- String columnIds =
conf.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR);
- if (!columnIds.isEmpty() && columnIds.charAt(0) == ',') {
- conf.set(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR,
columnIds.substring(1));
- LOG.debug("The projection Ids: {{}} start with ','. First comma is
removed", columnIds);
+ synchronized (conf) {
+ String columnIds =
conf.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR, "");
+ String cleanedColumnIds = Arrays.stream(columnIds.split(","))
+ .map(String::trim)
+ .filter(id -> !id.isEmpty())
+ .collect(Collectors.joining(","));
+ if (!cleanedColumnIds.equals(columnIds)) {
+ conf.set(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR,
cleanedColumnIds);
+ LOG.debug("The projection Ids: {{}} contained blank entries. Cleaned
to: {{}}", columnIds, cleanedColumnIds);
+ }
}
}
}
diff --git
a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieRealtimeRecordReaderUtils.java
b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieRealtimeRecordReaderUtils.java
index 59dd14cff733..c3f643abcda6 100644
---
a/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieRealtimeRecordReaderUtils.java
+++
b/hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/utils/HoodieRealtimeRecordReaderUtils.java
@@ -273,15 +273,25 @@ public class HoodieRealtimeRecordReaderUtils {
// /org/apache/hadoop/hive/serde2/ColumnProjectionUtils.java#L188}
// Field Names -> {@link
https://github.com/apache/hive/blob/f37c5de6c32b9395d1b34fa3c02ed06d1bfbf6eb/serde/src/java
// /org/apache/hadoop/hive/serde2/ColumnProjectionUtils.java#L229}
- String[] fieldOrdersWithDups = fieldOrderCsv.isEmpty() ? new String[0] :
fieldOrderCsv.split(",");
+ // Defence in depth.
HoodieRealtimeInputFormatUtils#cleanProjectionColumnIds now drops blank ids
from the
+ // JobConf before any reader runs, which is what HIVE-22438 produces for
SELECT COUNT(*) on Hive before
+ // 3.0.0, so blanks should no longer arrive here. Callers that assemble
the csv without going through that
+ // conf still can, and a blank token would otherwise reach
Integer.parseInt below and fail with a bare
+ // NumberFormatException carrying neither projection list. Trim before
filtering so a padded id parses and
+ // de-duplicates as the same entry rather than as a distinct one.
+ String[] fieldOrdersWithDups = Arrays.stream(fieldOrderCsv.split(","))
+ .map(String::trim).filter(id -> !id.isEmpty()).toArray(String[]::new);
Set<String> fieldOrdersSet = new
LinkedHashSet<>(Arrays.asList(fieldOrdersWithDups));
String[] fieldOrders = fieldOrdersSet.toArray(new String[0]);
List<String> fieldNames = fieldNameCsv.isEmpty() ? new ArrayList<>() :
Arrays.stream(fieldNameCsv.split(",")).collect(Collectors.toList());
Set<String> fieldNamesSet = new LinkedHashSet<>(fieldNames);
if (fieldNamesSet.size() != fieldOrders.length) {
+ // Report the de-duplicated counts, since those are what was compared:
the raw name count can print
+ // two equal numbers for a real mismatch.
throw new HoodieException(String
- .format("Error ordering fields for storage read. #fieldNames: %d,
#fieldPositions: %d",
- fieldNames.size(), fieldOrders.length));
+ .format("Error ordering fields for storage read.
#distinctFieldNames: %d, #distinctFieldPositions: %d, "
+ + "read column names: [%s], read column ids: [%s]",
+ fieldNamesSet.size(), fieldOrders.length, fieldNameCsv,
fieldOrderCsv));
}
TreeMap<Integer, String> orderedFieldMap = new TreeMap<>();
String[] fieldNamesArray = fieldNamesSet.toArray(new String[0]);
diff --git
a/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/TestSchemaEvolutionContext.java
b/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/TestSchemaEvolutionContext.java
new file mode 100644
index 000000000000..2b0122478eaf
--- /dev/null
+++
b/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/TestSchemaEvolutionContext.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.hudi.hadoop;
+
+import org.apache.hudi.common.schema.internal.Types;
+import org.apache.hudi.exception.HoodieException;
+
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.hive.serde.serdeConstants;
+import org.apache.hadoop.hive.serde2.ColumnProjectionUtils;
+import org.apache.hadoop.mapred.FileSplit;
+import org.apache.hadoop.mapred.JobConf;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Covers how {@link SchemaEvolutionContext#setColumnTypeList} reads {@code
hive.io.file.readcolumn.ids}.
+ * Every id there is parsed with {@code Integer#parseInt}, so the blank
entries HIVE-22438 leaves behind and
+ * the unset key both used to surface as a bare {@code NumberFormatException}
or an NPE rather than as the
+ * size mismatch the method already reports.
+ */
+public class TestSchemaEvolutionContext {
+
+ // col2 is a record whose nested field was renamed, so setColumnTypeList has
something to write back:
+ // primitive types are returned unchanged, and an unchanged rewrite cannot
tell a right pairing from a wrong one.
+ private static final List<Types.Field> TWO_FIELDS = Arrays.asList(
+ Types.Field.get(0, "col1", Types.StringType.get()),
+ Types.Field.get(1, "col2", Types.RecordType.get(
+ Types.Field.get(2, "renamed", Types.StringType.get()))));
+
+ private JobConf job;
+ private SchemaEvolutionContext context;
+
+ @BeforeEach
+ public void setUp() throws IOException {
+ job = new JobConf();
+ // Keeps the constructor off the table: it is the projection-id parsing
below that is under test.
+ job.setBoolean("hudi.hive.schema.evolution", false);
+ job.set(serdeConstants.LIST_COLUMN_TYPES,
"string,struct<original:string>");
+ context = new SchemaEvolutionContext(new FileSplit(new
Path("file:///tmp/unused"), 0, 0, (String[]) null), job);
+ }
+
+ @Test
+ public void testSetColumnTypeListWithUnsetReadColumnIds() {
+ job.unset(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR);
+ HoodieException thrown = assertThrows(HoodieException.class, () ->
context.setColumnTypeList(job, TWO_FIELDS));
+ assertTrue(thrown.getMessage().contains("is not equal to projection
columns"),
+ () -> "Expected the size mismatch rather than an NPE, got: " +
thrown.getMessage());
+ }
+
+ @Test
+ public void testSetColumnTypeListWithBlankReadColumnId() {
+ job.set(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR, ",0");
+ HoodieException thrown = assertThrows(HoodieException.class, () ->
context.setColumnTypeList(job, TWO_FIELDS));
+ assertTrue(thrown.getMessage().contains("is not equal to projection
columns"),
+ () -> "Expected the size mismatch rather than a NumberFormatException,
got: " + thrown.getMessage());
+ assertTrue(thrown.getMessage().contains("#nonBlankIds: 1,
#projectionColumns: 2"),
+ () -> "the message should carry the counts that were compared, got: "
+ thrown.getMessage());
+ }
+
+ @Test
+ public void testSetColumnTypeListIgnoresBlankAndPaddedReadColumnIds() {
+ job.set(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR, ", 0 ,1");
+ assertDoesNotThrow(() -> context.setColumnTypeList(job, TWO_FIELDS));
+ assertEquals("string,struct<renamed:string>",
job.get(serdeConstants.LIST_COLUMN_TYPES),
+ "id 1 should still pair with col2, so the renamed nested field lands
in the second slot");
+ }
+}
diff --git
a/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/realtime/TestHoodieRealtimeRecordReader.java
b/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/realtime/TestHoodieRealtimeRecordReader.java
index 4b1417700178..15cf2472e2bc 100644
---
a/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/realtime/TestHoodieRealtimeRecordReader.java
+++
b/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/realtime/TestHoodieRealtimeRecordReader.java
@@ -86,6 +86,7 @@ import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
import java.io.File;
import java.io.FileOutputStream;
@@ -750,8 +751,15 @@ public class TestHoodieRealtimeRecordReader {
}
}
- @Test
- public void testIncrementalWithReplace() throws Exception {
+ /**
+ * The {@code blankProjectionIds} arm drives the two changed methods
together: Hive's read-column ids reach
+ * {@code HoodieParquetRealtimeInputFormat#getRecordReader} with the blanks
HIVE-22438 leaves behind,
+ * {@code cleanProjectionColumnIds} drops them, and {@code orderFields}
pairs what is left. Two blanks, not
+ * one, because the previous sanitiser stripped a single leading comma.
+ */
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testIncrementalWithReplace(boolean blankProjectionIds) throws
Exception {
// initial commit
HoodieSchema schema =
HoodieSchemaUtils.addMetadataFields(SchemaTestUtil.getEvolvedSchema());
HoodieTestUtils.init(storageConf, basePath.toString(),
HoodieTableType.MERGE_ON_READ);
@@ -779,12 +787,17 @@ public class TestHoodieRealtimeRecordReader {
JobConf newJobConf = new JobConf(baseJobConf);
List<HoodieSchemaField> fields = schema.getFields();
setHiveColumnNameProps(fields, newJobConf, false);
+ if (blankProjectionIds) {
+ newJobConf.set(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR,
+ ",," +
newJobConf.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR));
+ }
newJobConf.set("columns.types",
"string,string,string,string,string,string,string,string,bigint,string,string");
RecordReader<NullWritable, ArrayWritable> reader =
inputFormat.getRecordReader(splits[0], newJobConf, Reporter.NULL);
// use reader to read log file.
NullWritable key = reader.createKey();
ArrayWritable value = reader.createValue();
+ int recordCnt = 0;
while (reader.next(key, value)) {
Writable[] values = value.get();
// since we set incremental start commit as 0 and commit_number as 1.
@@ -792,7 +805,9 @@ public class TestHoodieRealtimeRecordReader {
assertEquals("100", values[0].toString());
key = reader.createKey();
value = reader.createValue();
+ recordCnt++;
}
+ assertEquals(100, recordCnt);
reader.close();
}
diff --git
a/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/utils/TestHoodieRealtimeInputFormatUtils.java
b/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/utils/TestHoodieRealtimeInputFormatUtils.java
index deecaca5c706..350718c4fea1 100644
---
a/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/utils/TestHoodieRealtimeInputFormatUtils.java
+++
b/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/utils/TestHoodieRealtimeInputFormatUtils.java
@@ -22,9 +22,19 @@ import org.apache.hudi.common.testutils.HoodieTestUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hive.metastore.api.hive_metastoreConstants;
+import org.apache.hadoop.hive.serde2.ColumnProjectionUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
public class TestHoodieRealtimeInputFormatUtils {
@@ -45,4 +55,53 @@ public class TestHoodieRealtimeInputFormatUtils {
hadoopConf.set(hive_metastoreConstants.META_TABLE_PARTITION_COLUMNS, "");
HoodieRealtimeInputFormatUtils.addProjectionField(hadoopConf,
hadoopConf.get(hive_metastoreConstants.META_TABLE_PARTITION_COLUMNS,
"").split("/"));
}
+
+ private String clean(String columnIds) {
+ hadoopConf.set(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR, columnIds);
+ HoodieRealtimeInputFormatUtils.cleanProjectionColumnIds(hadoopConf);
+ return hadoopConf.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR);
+ }
+
+ private static Stream<Arguments> projectionIdCases() {
+ return Stream.of(
+ Arguments.of(",2,0", "2,0", "a leading blank id should be dropped"),
+ Arguments.of(",,2,0", "2,0",
+ "Hive appending empty ids repeatedly yields more than one leading
blank"),
+ Arguments.of("3,,2,0", "3,2,0",
+ "an interior blank, which leading-comma stripping never reached;
the resulting pairing is still "
+ + "unsound per #19506, this only stops the bare
NumberFormatException"),
+ Arguments.of(" 2 , 0 ", "2,0",
+ "ids are trimmed, so a padded id parses and de-duplicates as the
same entry"),
+ Arguments.of("2,0", "2,0", "a list with nothing to drop keeps its
value"),
+ Arguments.of("", "", "an empty list keeps its value"));
+ }
+
+ /**
+ * HIVE-22438: for {@code SELECT COUNT(*)} on Hive before 3.0.0 the
read-column ids arrive empty and Hive
+ * combines them into e.g. {@code ",2,0,3"}. Every consumer of this conf
value parses the ids with
+ * {@code Integer#parseInt}, so any blank entry left behind fails with a
bare {@code NumberFormatException}.
+ *
+ * <p>One case per shape, so a regression in the first does not hide the
rest: only three of the six differ
+ * from what the previous single-leading-comma sanitiser produced.
+ */
+ @ParameterizedTest(name = "[{index}] \"{0}\" -> \"{1}\"")
+ @MethodSource("projectionIdCases")
+ public void testCleanProjectionColumnIdsDropsBlankEntries(String columnIds,
String expected, String why) {
+ assertEquals(expected, clean(columnIds), why);
+ }
+
+ /**
+ * The key is unset until something projects a column, and {@code conf.get}
had no default here, so this
+ * threw {@code NullPointerException} before. Same shape as the fix applied
to {@code addProjectionField}.
+ *
+ * <p>This also pins the write-back guard: without it the empty default
would be written back and the key
+ * would no longer read as unset.
+ */
+ @Test
+ public void testCleanProjectionColumnIdsWithUnsetKey() {
+ hadoopConf.unset(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR);
+ assertDoesNotThrow(() ->
HoodieRealtimeInputFormatUtils.cleanProjectionColumnIds(hadoopConf));
+ assertNull(hadoopConf.get(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR),
+ "an unset key should stay unset rather than being written back as
empty");
+ }
}
diff --git
a/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/utils/TestHoodieRealtimeRecordReaderUtils.java
b/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/utils/TestHoodieRealtimeRecordReaderUtils.java
new file mode 100644
index 000000000000..a81a2d49f9bb
--- /dev/null
+++
b/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/utils/TestHoodieRealtimeRecordReaderUtils.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.hudi.hadoop.utils;
+
+import org.apache.hudi.exception.HoodieException;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests {@link HoodieRealtimeRecordReaderUtils#orderFields}, which maps Hive's
+ * {@code hive.io.file.readcolumn.names} and {@code
hive.io.file.readcolumn.ids} onto an ordered
+ * projection list.
+ */
+public class TestHoodieRealtimeRecordReaderUtils {
+
+ @Test
+ public void testOrderFieldsSortsNamesByTheirHivePosition() {
+ assertEquals(Arrays.asList("rider", "driver", "fare"),
+ HoodieRealtimeRecordReaderUtils.orderFields("driver,fare,rider",
"1,2,0", Collections.emptyList()));
+ }
+
+ @Test
+ public void testOrderFieldsReturnsEmptyForEmptyInput() {
+ assertEquals(Collections.emptyList(),
+ HoodieRealtimeRecordReaderUtils.orderFields("", "",
Collections.emptyList()));
+ }
+
+ /**
+ * Hive can repeat a name in the read-column list while keeping ids unique,
which the method
+ * deliberately tolerates by de-duplicating both sides before pairing them.
The duplicate is deliberately
+ * not last: that is the one position where pairing from the de-duplicated
list and from the raw one give
+ * the same answer, so a duplicate on the tail pins the count but not the
pairing.
+ */
+ @Test
+ public void testOrderFieldsDeduplicatesRepeatedNames() {
+ assertEquals(Arrays.asList("rider", "driver"),
+ HoodieRealtimeRecordReaderUtils.orderFields("rider,rider,driver",
"0,1", Collections.emptyList()));
+ }
+
+ /**
+ * The counts compared are the de-duplicated ones, so the failure has to
report those. Reporting the raw
+ * name count instead prints two equal numbers for a real mismatch, which is
unusable when diagnosing
+ * something like HUDI-1286. This is the case that fails without the
production change.
+ */
+ @Test
+ public void testOrderFieldsMismatchReportsDistinctCountsWhenNamesRepeat() {
+ HoodieException thrown = assertThrows(HoodieException.class, () ->
+ HoodieRealtimeRecordReaderUtils.orderFields("rider,driver,fare,fare",
"0,1,2,3", Collections.emptyList()));
+ assertTrue(thrown.getMessage().contains("#distinctFieldNames: 3"),
+ () -> "Expected the de-duplicated name count, got: " +
thrown.getMessage());
+ assertTrue(thrown.getMessage().contains("#distinctFieldPositions: 4"),
+ () -> "Expected the position count, got: " + thrown.getMessage());
+ }
+
+ /** A mismatch with no duplicates on either side still has to carry both
projection lists. */
+ @Test
+ public void testOrderFieldsMismatchReportsBothProjectionLists() {
+ HoodieException thrown = assertThrows(HoodieException.class, () ->
+ HoodieRealtimeRecordReaderUtils.orderFields("rider,driver", "0,1,2",
Collections.emptyList()));
+ assertTrue(thrown.getMessage().contains("read column names:
[rider,driver]")
+ && thrown.getMessage().contains("read column ids: [0,1,2]"),
+ () -> "Expected both projection lists, got: " + thrown.getMessage());
+ }
+
+ /**
+ * HIVE-22438: for {@code SELECT COUNT(*)} on Hive before 3.0.0 the
read-column ids arrive empty and Hive
+ * combines them into e.g. {@code ",,2,0,3,5"}. {@code
cleanProjectionColumnIds} strips every blank id from
+ * the conf, so no production path delivers a blank here any more. This is
defence in depth for callers
+ * that build the csv themselves.
+ */
+ @Test
+ public void testOrderFieldsIgnoresBlankIdTokens() {
+ assertEquals(Arrays.asList("c", "b"),
+ HoodieRealtimeRecordReaderUtils.orderFields("b,c", ",2,0",
Collections.emptyList()),
+ "a leading blank id token should be ignored rather than parsed");
+ assertEquals(Arrays.asList("c", "b"),
+ HoodieRealtimeRecordReaderUtils.orderFields("b,c", ",,2,0",
Collections.emptyList()),
+ "more than one blank token can arrive when Hive appends empty ids
repeatedly");
+ assertEquals(Arrays.asList("b", "a", "c", "ts"),
+ HoodieRealtimeRecordReaderUtils.orderFields("a,b,c,ts", ",2,0,3,5",
Collections.emptyList()),
+ "the numbers reported in #14673: four names against five id tokens,
one of them blank");
+ assertEquals(Arrays.asList("c", "b"),
+ HoodieRealtimeRecordReaderUtils.orderFields("b,c", " 2 , 0 ",
Collections.emptyList()),
+ "ids are trimmed, so a padded id parses and de-duplicates as the same
entry");
+ }
+
+ /**
+ * A blank token only reaches {@code Integer.parseInt} when it makes the two
counts line up, which needs one
+ * more name than the cases above. That is the shape that failed with a bare
{@code NumberFormatException}
+ * carrying neither projection list; dropping the blank turns it into the
{@code HoodieException} that does
+ * carry both.
+ */
+ @Test
+ public void testOrderFieldsMismatchAfterBlankIdFilteringReportsRawIdList() {
+ HoodieException thrown = assertThrows(HoodieException.class, () ->
+ HoodieRealtimeRecordReaderUtils.orderFields("a,b,c", ",2,0",
Collections.emptyList()));
+ assertTrue(thrown.getMessage().contains("read column ids: [,2,0]"),
+ () -> "Expected the raw id list, got: " + thrown.getMessage());
+ }
+
+ /**
+ * Hive tolerates duplicate ids as well as duplicate names, which is why
both sides are de-duplicated
+ * before pairing. Only the name side was pinned. As above, the duplicate is
not on the tail.
+ */
+ @Test
+ public void testOrderFieldsDeduplicatesRepeatedIds() {
+ assertEquals(Arrays.asList("rider", "driver"),
+ HoodieRealtimeRecordReaderUtils.orderFields("rider,driver", "0,0,1",
Collections.emptyList()));
+ }
+
+ /**
+ * HUDI-5308 (#7355) removed the filter that dropped partitioning fields
from the name list before the
+ * comparison, so a partition column in that list now counts towards it.
Pins that removal.
+ */
+ @Test
+ public void testOrderFieldsNoLongerFiltersPartitionFields() {
+ HoodieException thrown = assertThrows(HoodieException.class, () ->
HoodieRealtimeRecordReaderUtils.orderFields(
+ "rider,driver,partition_path", "0,1",
Collections.singletonList("partition_path")));
+ assertTrue(thrown.getMessage().contains("#distinctFieldNames: 3,
#distinctFieldPositions: 2"),
+ () -> "the partition column should still count towards the comparison,
got: " + thrown.getMessage());
+ }
+}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHiveTableSchemaEvolution.java
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHiveTableSchemaEvolution.java
index d1b2e73a0812..fafd3c1e32a6 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHiveTableSchemaEvolution.java
+++
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHiveTableSchemaEvolution.java
@@ -160,7 +160,9 @@ public class TestHiveTableSchemaEvolution {
jobConf.set(HoodieCommonConfig.SCHEMA_EVOLUTION_ENABLE.key(), "true");
jobConf.set(ColumnProjectionUtils.READ_ALL_COLUMNS, "false");
jobConf.set(ColumnProjectionUtils.READ_COLUMN_NAMES_CONF_STR,
"col1,col2_new");
- jobConf.set(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR, "6,7");
+ // Leading blanks are what HIVE-22438 puts in this conf value. The cow arm
builds a HoodieParquetInputFormat
+ // directly, so this is the only test that drives cleanProjectionColumnIds
ahead of SchemaEvolutionContext.
+ jobConf.set(ColumnProjectionUtils.READ_COLUMN_IDS_CONF_STR, ",,6,7");
jobConf.set(serdeConstants.LIST_COLUMNS,
"_hoodie_commit_time,_hoodie_commit_seqno,"
+
"_hoodie_record_key,_hoodie_partition_path,_hoodie_file_name,col0,col1,col2_new");
jobConf.set(serdeConstants.LIST_COLUMN_TYPES,
"string,string,string,string,string,int,double,string");