Skip to content

fix(flink): unwrap the list element type when creating vectors for nested arrays - #20337

Open
skywalker0618 wants to merge 3 commits into
apache:masterfrom
skywalker0618:shihuanl/flink-nested-array-element-vector
Open

skywalker0618 wants to merge 3 commits into
apache:masterfrom
skywalker0618:shihuanl/flink-nested-array-element-vector

Conversation

@skywalker0618

@skywalker0618 skywalker0618 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Describe the issue this Pull Request addresses

Reading a Hudi table through the Flink COW Parquet reader fails with a NullPointerException when a column is an array of rows that itself contains an array of rows, e.g. ARRAY<ROW<name STRING, items ARRAY<ROW<id STRING>>>>:

java.lang.NullPointerException
  at org.apache.hudi.table.format.cow.vector.reader.NestedColumnReader.readRow(NestedColumnReader.java:112)

This is a follow-up to the Dremel-style nested Parquet reader backport (FLINK-35702, #18636 / #18700 / #18809). We hit it streaming a production COW table with deeply nested schemas.

Root cause: in ParquetSplitReaderUtil.createWritableColumnVector, the ARRAY branch passes the Parquet LIST group itself as the physical type of the array's element. For an array of rows, the element ROW then looks its fields up by name in the LIST wrapper instead of in the element group, finds none, and fills every slot with a constant placeholder vector. For leaf fields this goes unnoticed (the array of rows silently reads as all-null fields), but the placeholder for an inner array has no child vector, so NestedColumnReader.readRow dereferences null.

Summary and Changelog

Arrays of rows that contain nested arrays of rows can now be read by the Flink COW reader.

This PR fixes hudi-flink1.18.x only. The other Flink modules (1.19, 1.20, 2.0, 2.1) have the same code and will get the same change in a follow-up PR once this one is merged.

  • hudi-flink1.18.x ParquetSplitReaderUtil: the ARRAY branch of createWritableColumnVector now locates the element type using the same Parquet LIST backward-compatibility rules as getArrayElementColumn, so the vector tree lines up with the ParquetField tree:

    • standard 3-level lists (LIST -> repeated group -> element),
    • 2-level lists whose repeated group is named array or <list name>_tuple,
    • repeated groups with multiple fields and repeated primitives (the repeated type is the element).

    The depth passed down is incremented at each unwrapped level, matching the ParquetField construction. Row fields are still resolved by name, so schema evolution (fields missing from the file) keeps working. The unwrapping lives in a new unwrapListElementType helper.

  • hudi-flink1.18.x NestedColumnReader: inside an array, a row has one position per element, which can exceed the batch size that the null placeholders of fields absent from the file were sized with. readRow now grows those placeholders to the position count; otherwise reading an evolved array of rows fails with an ArrayIndexOutOfBoundsException once a batch holds more elements than rows.

  • New TestParquetSplitReaderUtil in hudi-flink1.18.x. The tests write real Parquet files with ExampleParquetWriter and read them back through ParquetColumnarRowSplitReader:

    • array of rows containing an array of rows, parameterized over the three list encodings (3-level, 2-level array, 2-level _tuple), including empty and null arrays,
    • the same shape with row fields missing from the file (schema evolution), both with the default batch size and with a batch size smaller than the element count,
    • 3-level and 2-level arrays of primitives.

Impact

Fixes a read failure for nested array-of-row schemas, and arrays of rows without a nested array now return their actual field values instead of nulls. No public API or config changes.

Risk Level

Low. The change is confined to how the element type is located when building column vectors for arrays, reusing the rules getArrayElementColumn already applies when building the reader tree, plus resizing placeholder vectors that would otherwise be too small. Verification:

  • Without the ParquetSplitReaderUtil fix, the nested-array tests fail with the NullPointerException above (the primitive-array tests pass either way).
  • Without the NestedColumnReader fix, the three small-batch tests fail with an ArrayIndexOutOfBoundsException in AbstractHeapVector.isNullAt.
  • With both fixes, all 11 tests pass with -Dflink1.18, as do the existing ITTestHoodieDataSource#testParquet* integration tests (17).
  • checkstyle:check passes for hudi-flink1.18.x.
  • The element-type fix has been running in a production Flink streaming job reading a ~10B-row COW table with these nested schemas.

Documentation Update

none

Contributor's checklist

  • Read through contributor's guide
  • Enough context is provided in the sections above
  • Adequate tests were added if applicable

@github-actions github-actions Bot added the size:XL PR with lines of changes > 1000 label Oct 14, 2026
@skywalker0618
skywalker0618 force-pushed the shihuanl/flink-nested-array-element-vector branch from fb2c4fa to bb17ac2 Compare October 14, 2026 21:59
@github-actions github-actions Bot added size:L PR with lines of changes in (300, 1000] and removed size:XL PR with lines of changes > 1000 labels Oct 14, 2026

@hudi-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for working on this! This makes the Flink 1.18 COW reader build the vector tree for arrays by unwrapping the LIST element with the same rules as the ParquetField tree, which fixes the NPE for arrays of rows that contain arrays of rows; there's one non-blocking note inline about an existing schema-evolution edge case. Please take a look at any inline comments, and this should be ready for a Hudi committer or PMC member to take it from here. A couple of small readability suggestions below.


@ParameterizedTest
@EnumSource(ListEncoding.class)
void testReadArrayOfRowWithFieldsMissingFromFile(ListEncoding encoding) throws IOException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 non-blocking: the null placeholder for a missing field is sized to batchSize, so once a batch holds more array elements than rows (e.g. batchSize=2 with two rows of two items each), isNullAt on added_scores/added_tag_field throws ArrayIndexOutOfBoundsException; this also fails before this PR, so would it make sense to track it as a follow-up, maybe with a small-batch variant of this test?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, confirmed: with a batch size of 2 the evolved array-of-rows test fails with an ArrayIndexOutOfBoundsException in AbstractHeapVector.isNullAt, since inside an array the row has one position per element. Fixed in NestedColumnReader.readRow by growing the null placeholders of absent fields to the position count, and added testReadArrayOfRowWithFieldsMissingFromFileInBatchSmallerThanElementCount (batch size 2, all three list encodings), which fails without the fix.

ArrayType arrayType = (ArrayType) fieldType;
// Locate the element type with the same LIST backward-compatibility rules as
// getArrayElementColumn, so that the vectors line up with the ParquetField tree.
Type elementType = physicalType;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nit: would it make sense to pull the element-type lookup in this ARRAY case into a small helper (e.g. unwrapListElementType)? It would keep createWritableColumnVector shorter and avoid the block braces on the case.

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

@skywalker0618 skywalker0618 Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

int index = recordIndex[0]++;
if (index == 0) {
Group items = record.addGroup("items");
addItem(encoding, items, "a", "x", "y");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nit: itemsType just wraps new ArrayType(...) and rowType just wraps RowType.of(...); could you inline them at the call sites?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inlined itemsType since it only wrapped new ArrayType(...). I kept rowType, as it reads better with the field names listed before the types than building RowType.RowFields inline. Also pulled the evolved items type, now used by two tests, into evolvedItemsType().

…sted arrays

createWritableColumnVector passed the LIST group itself as the physical type of an
array's element. For an array of rows the element ROW then looked its fields up by
name in the LIST wrapper, found none, and filled every slot with a constant
placeholder vector. Reading array<row<..., array<row<...>>>> then failed with a
NullPointerException in NestedColumnReader.readRow, because the placeholder for the
inner array has no child vector.

Locate the element type with the same Parquet LIST backward-compatibility rules as
getArrayElementColumn (3-level, 2-level "array" / "<name>_tuple", multi-field and
primitive repeated types), so the vector tree lines up with the ParquetField tree.
Row fields are still resolved by name, preserving schema evolution.

Inside an array, a row has one position per element, which can exceed the batch size
that the null placeholders of fields absent from the file were sized with. Grow those
placeholders to the position count in NestedColumnReader.readRow, so that reading an
evolved array of rows doesn't fail with an ArrayIndexOutOfBoundsException.
@skywalker0618
skywalker0618 force-pushed the shihuanl/flink-nested-array-element-vector branch from bb17ac2 to 3ea6452 Compare October 14, 2026 22:26
@skywalker0618

Copy link
Copy Markdown
Contributor Author

@danny0405 Do you mind taking a look at this bug fix as well? Fix an NPE issue in parquet reader. Tested internally in Uber. Once this is merged into flink 1.18 I will create PR for flink 1.19 and onwards. Thanks!

@codecov-commenter

codecov-commenter commented Oct 14, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.75%. Comparing base (6bbf798) to head (f9f2acf).
⚠️ Report is 15 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #20337      +/-   ##
============================================
+ Coverage     80.71%   80.75%   +0.03%     
- Complexity    35152    35313     +161     
============================================
  Files          2553     2562       +9     
  Lines        143556   144143     +587     
  Branches      17482    17629     +147     
============================================
+ Hits         115875   116397     +522     
- Misses        19737    19771      +34     
- Partials       7944     7975      +31     
Components Coverage Δ
hudi-common 84.17% <ø> (-0.01%) ⬇️
hudi-client 83.95% <ø> (+0.01%) ⬆️
hudi-flink 86.07% <ø> (-0.01%) ⬇️
hudi-spark-datasource 74.19% <ø> (-0.01%) ⬇️
hudi-utilities 78.36% <ø> (+0.18%) ⬆️
hudi-cli 70.80% <ø> (ø)
hudi-hadoop 71.17% <ø> (+<0.01%) ⬆️
hudi-sync 76.85% <ø> (+0.22%) ⬆️
hudi-io 81.57% <ø> (+0.04%) ⬆️
hudi-timeline-service 83.41% <ø> (ø)
hudi-cloud 81.01% <ø> (+0.01%) ⬆️
hudi-kafka-connect 57.90% <ø> (+3.41%) ⬆️
Flag Coverage Δ
common-and-other-modules 52.68% <ø> (+0.11%) ⬆️
flink-integration-tests 49.64% <ø> (+<0.01%) ⬆️
hadoop-mr-java-client 43.95% <ø> (+<0.01%) ⬆️
integration-tests 13.40% <ø> (-0.05%) ⬇️
spark-client-hadoop-common 38.52% <ø> (-0.16%) ⬇️
spark-java-tests 52.57% <ø> (-0.23%) ⬇️
spark-scala-tests 47.54% <ø> (-0.20%) ⬇️
utilities 37.01% <ø> (+0.12%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 47 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hudi-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for working on this! This fixes the Flink 1.18 COW reader so the element vector of an ARRAY uses the unwrapped Parquet list element, and grows absent-field placeholders inside arrays to the element count; one note inline on which Flink module the fix and its tests land in. Please take a look at any inline comments, and this should be ready for a Hudi committer or PMC member to take it from here. A couple of small readability suggestions on the reader change.

return new HeapDecimalVector(batchSize);
case ARRAY:
ArrayType arrayType = (ArrayType) fieldType;
Pair<Type, Integer> element = unwrapListElementType(physicalType, depth);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 non-blocking: hudi-flink2.2.x is the default profile and the only one CI runs (the flink1.18 matrix entries in bot.yml are commented out), and it has the same ARRAY branch bug, so the new tests won't run in CI and the default bundle still hits the NPE. Would it make sense to land this in hudi-flink2.2.x too (or in place of 1.18), and add 2.2.x to the follow-up list in the description?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure I will create a new PR to cover other flink branches once this is merged

// Inside an array the row has one position per element, which can exceed the batch size the
// placeholders of absent fields were sized with, so grow them to the position count.
int positionsCount = rowPosition.getPositionsCount();
for (int i = 0; i < children.size(); i++) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nit: could you hoist ((RowType) field.getType()) into a local variable outside the loop, and consider extracting this placeholder-growing loop into a small helper so readRow stays focused?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hoisted the RowType cast out of the loop. Disregard the helper one as it would have only 1 caller.

*/
private static Pair<Type, Integer> unwrapListElementType(Type listType, int depth) {
Type elementType = listType;
String listName = null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nit: unwrapListElementType duplicates the unwrapping logic of getArrayElementColumn; would it make sense to note (or share) which parts must stay in sync so they don't drift apart?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Javadoc of unwrapListElementType already says it applies the same backward-compatibility rules as getArrayElementColumn. Sharing the code doesn't fit well, since one walks the Parquet Type and the other builds the ParquetField tree, so I'll leave it as is.

@hudi-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for working on this! This PR makes the Flink 1.18 COW reader's vector tree pick the same LIST element as the ParquetField tree, and resizes the placeholders for absent fields to the row's position count, so arrays of rows that hold nested arrays of rows can now be read. No issues flagged from this automated pass — a Hudi committer or PMC member can take it from here for a final review.

cc @yihua

@cshuo cshuo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thks for the fix. Since this reader was ported from Flink, have you checked whether the latest upstream Flink implementation already handles this deep nested-array case? If so, could we backport the relevant changes while preserving Hudi’s schema-evolution support?

batchSize,
createWritableColumnVector(
batchSize, arrayType.getElementType(), physicalType, descriptors, depth));
batchSize, arrayType.getElementType(), element.getLeft(), descriptors, element.getRight()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this change, ARRAY<ROW<...>> containing INT32/INT64-encoded decimals was readable because NestedPrimitiveColumnReader replaced the initial placeholder with a correctly decoded vector. Unwrapping now reaches the actual decimal field during allocation, where the existing DECIMAL branch accepts only BINARY/FIXED_LEN_BYTE_ARRAY, so those files fail during reader construction. Could we preserve support for both integer encodings and add regression tests? Current Hudi writers use fixed-length bytes, so this is a compatibility regression for other valid Parquet files, rather than a failure of the default Hudi write path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch, thanks! The allocator has accepted only bytes for decimals since HUDI-2430, and arrays of rows only avoided that check because of the unwrapping bug fixed here. Fixed the allocation and added UTs to cover.

&& repeatedType.getLogicalTypeAnnotation() == null
&& repeatedType.asGroupType().getFieldCount() == 1
&& !repeatedType.getName().equals("array")
&& (listName == null || !repeatedType.getName().equals(listName + "_tuple"));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bare repeated group named <parent-name>_tuple with a single primitive field previously read correctly as an array of rows. When this helper receives that repeated group directly, the loop is skipped and listName remains null. This condition then unwraps the actual element ROW to its primitive child, causing a ClassCastException in the ROW allocator. Meanwhile, getArrayElementColumn retains the actual parent name and correctly preserves the ROW. Could we retain that parent context so both paths select the same element type?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed and added test cases too

int positionsCount = rowPosition.getPositionsCount();
RowType rowType = (RowType) field.getType();
for (int i = 0; i < children.size(); i++) {
if (children.get(i) == null && vectorLength(finalChildrenVectors[i]) < positionsCount) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a ROW's last existing child is an array and the parent rows are non-null, calculateRowOffsets returns the unfiltered leaf-level count, including deeper array elements. That counting bug predates this PR, but using it to resize missing-field vectors is new. With batchSize=2 and 100 nested elements per record, the missing-field capacity grows from 2 to 201 while the parent capacity remains 2, adding allocation and null-filling work. Could we use the owning ROW's actual required capacity, accounting for lookahead, rather than the deeper element count?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, fixed. I found this problem exists in flink too, and was brought in hudi during the backport.

@skywalker0618

Copy link
Copy Markdown
Contributor Author

Thks for the fix. Since this reader was ported from Flink, have you checked whether the latest upstream Flink implementation already handles this deep nested-array case? If so, could we backport the relevant changes while preserving Hudi’s schema-evolution support?

I checked the latest Flink master: there's no newer fix to backport. The last changes there are FLINK-35702 and FLINK-36709, and Hudi already has both. Flink handles an array of rows in its ROW allocator by always stepping down two levels and matching fields by position, so it only supports the standard three-level list and would break Hudi's name-based schema evolution. So this PR unwraps in the ARRAY allocator instead, using the same rules as getArrayElementColumn.

@skywalker0618

Copy link
Copy Markdown
Contributor Author

@cshuo Do you mind taking another look? Thanks!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L PR with lines of changes in (300, 1000]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants