Skip to content

branch-4.2 [fix](nereids) Disambiguate NULL/OFFSET metadata from same-named nested fields (#65805) - #68314

Open
englefly wants to merge 3 commits into
apache:branch-4.2from
englefly:pick-65805-4.2
Open

englefly wants to merge 3 commits into
apache:branch-4.2from
englefly:pick-65805-4.2

Conversation

@englefly

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Cherry-pick of #65805 ([fix](nereids) Disambiguate NULL/OFFSET metadata from same-named nested fields) to branch-4.2.

The port also carries the two prerequisite commits that #65805 is written on top of, because branch-4.2 stopped at the state just before them:

commit PR why it is needed
branch-4.2 [fix](NestedColumnPruning) Rewrite the NestedColumnPruning meta-path handling ... #64535 branch-4.2's FE pruning code was exactly master-before-#64535; #65805's FE hunks are written against the rewritten meta-path handling.
branch-4.2 [feat](olap) Support lazy reading mode for pruned complex columns #59263 on master #59263 replaced #64535's MetaPathStriper approach again (predicate metadata paths are kept, normalizePredicateMetaPathForAllAccessPath / addPredicatePathsToFinalAllAccessPaths / expandMapStarPaths), and that is the code #65805 patches.
branch-4.2 [fix](nereids) Disambiguate NULL/OFFSET metadata from same-named nested fields #65805 the requested pick.

Master order for reference: #61888 → #62205 → #62315 → #62631 → #62304 → #63229 → #63736 → #64486 → #64535 → #59263 → … → #65805; branch-4.2 already had everything up to #64486 (ported by #68214).

What #65805 fixes

  • Versioned, type-selected access paths. TColumnAccessPath gets an optional version (TCOLUMN_ACCESS_PATH_VERSION_LEGACY = 0, TCOLUMN_ACCESS_PATH_VERSION_TYPED = 1) that is carried through thrift, protobuf and both FE/BE descriptor conversions. In the typed format the path type is authoritative: DATA selects data_access_path, META selects meta_access_path, and NULL/OFFSET are only ever emitted as typed META paths. New BEs still decode the legacy all-DATA encoding, so the supported rolling upgrade is: upgrade all BEs first, then let FEs send typed paths.
  • FE. Marks NULL/OFFSET collector contexts as META; checks the physical column's nullability instead of slot nullability, so an outer-join-nullable NOT NULL column no longer gets a [col, NULL] path; falls back to plain data reads for variant sub-columns; keeps the exact meta path when a sibling data path is also read; and keys pruning on the path type instead of string suffixes.
  • BE. Validates path versions and type-selected payloads; partitions current data / current metadata / descendant routing with explicit per-container ownership; derives NULL_MAP_ONLY / OFFSET_ONLY only when no current or predicate data path requires payload; keeps sibling META paths explicit; routes Map children by the logical KEYS/VALUES selectors instead of physical child column names; and rejects unrecognized Map selectors instead of silently pruning everything.

branch-4.2 adaptations

  • branch-4.2 keeps the FE access-path model as thrift types instead of master's ColumnAccessPath / DescriptorToThriftConverter, so the typed version is stamped in NestedColumnPruning.buildColumnAccessPaths (the branch's single producer) and preserved by SlotTypeReplacer.replaceIcebergAccessPathToId.
  • The master-only DescriptorToThriftConverter / DescriptorToThriftConverterTest files do not exist on this branch and were not added; the equivalent coverage lives in PruneNestedColumnTest, whose path() / metaPath() helpers (and VariantPruningLogicTest's) now carry the typed version.
  • Ported FE code was mapped onto this branch's names (TAccessPathType, TColumnAccessPath, isFunctionNullCheckPath) and keeps the segment-wise access-path comparator introduced by the branch's nested-column-pruning port.
  • gensrc/thrift/PaloInternalService.thrift: master uses field id 226 for enable_prune_nested_column, but branch-4.2 already uses 226-228, so the option was moved to the next free id (229).
  • segment_iterator.cpp keeps branch-4.2's _non_predicate_column_ids naming and drops the master-only _update_lsn_col_if_needed / _update_tso_col_if_needed calls from the ported hunk; branch-4.2 keeps its own common/compile_check_begin.h include in front of the new ScopedColumnIteratorReadPhase helper. _read_lazy_pruned_columns uses the branch's _schema_block_id_map because master's Schema::column_index() does not exist here, and FileColumnIterator::get_reader() was added as in master.
  • Regression suites lambda_null_pruning and map_contains_arg_pruning, and the unit tests for lambda access paths / Map lazy-read-by-rowids, only exist on master (they come from PRs outside this chain) and were not added.

Test

  • FE: ./build.sh --fe OK. ./run-fe-ut.sh --run 'org.apache.doris.nereids.rules.rewrite.PruneNestedColumnTest' → 63 passed; VariantPruningLogicTest 14, IcebergScanNodeTest 95, MaterializeProbeVisitorTest 8, StringEmptyToLengthRuleTest, PullUpProjectExprUnderTopNTest — all green.
  • BE: ./build.sh --be OK (the two ported BE test sources were additionally checked with the unit-test flags, -DBE_TEST -fno-access-control).
  • Regression: ./run-regression-test.sh --run -d nereids_rules_p0/column_pruning → 8 suites, 0 failed, including the new left_join_not_null_column suite that guards the outer-join null-map crash.

Behavior changed

Yes. Typed META access paths with an explicit version replace the legacy all-DATA encoding between new FEs and BEs (upgrade BEs before FEs); struct fields literally named NULL/OFFSET are now read correctly instead of being pruned as metadata; IS NULL on an outer-join-nullable NOT NULL column no longer crashes the BE; invalid Map descendant selectors now return an internal error instead of being silently ignored. The port also brings #59263, which defers reading non-predicate pruned complex columns until after predicate evaluation, reducing nested-column I/O.

Release note

None

englefly and others added 3 commits September 21, 2026 12:05
… meta-path handling to properly eliminate redundant NULL/OFFSET access paths and prevent unsafe BE reader mode combinations. (apache#64535)

1. Map-star path normalization (normalizeMapValueMetaOnlyAccessPaths)
- Rewrites [m, *, OFFSET] -> [m, KEYS] + [m, VALUES, OFFSET], and [m, *,
  NULL] -> [m, KEYS] + [m, VALUES, NULL]
- Unified OFFSET and NULL normalization via
  collectMapValueMetaOnlyAccessPaths(String metaSuffix)

2. Meta path stripping (MetaPathStriper)
- Two-level approach:
  - Level 1 (same-prefix): [prefix] > [prefix, OFFSET] > [prefix, NULL]
  - Level 2 (deeper-prefix): deeper path covers shallower meta path,
    merged into single stripMetaPathsByDeeperPrefix(metaSuffix) function
  - Type-aware */VALUES/KEYS equivalence for map columns via
    compareMetaPathPrefixCoverage
  - Depth guard prevents same-depth cross-type from being removed in Level 2
- Supplemental KEYS path handling when removing OFFSET paths

3. MV fragment meta path handling
- skipMetaPath flag in AccessPathExpressionCollector -- MV fragments skip
  meta path collection at the source, falling through to default visitor
  for data-only access

branch-4.2 adaptations:
- master-only FE access-path model types are mapped onto this branch's thrift
  types (ColumnAccessPath -> TColumnAccessPath, ColumnAccessPathType ->
  TAccessPathType, AccessPathInfo path components read via getAccessPathList).
- Kept branch-4.2's segment-wise path comparator (comparePathSegments) and the
  variant terminal branch in DataTypeAccessTree.setAccessByPath.
- Kept branch-4.2's struct_element(...) spelling in the ported unit tests.

Co-authored-by: Claude <noreply@anthropic.com>
…columns (apache#59263)

The subcolumns of a pruned complex-type column can fall into two categories:

1. Predicate columns -- columns required to evaluate filter predicates, which
   need to be read upfront.
2. Non-predicate columns -- columns that are not needed when evaluating filter
   predicates.

For non-predicate columns, Doris can defer reading them until after predicate
evaluation, reducing unnecessary I/O for nested columns.

This update also preserves predicate metadata paths separately from final
lazy-materialization data paths. Predicate evaluation may still need
current-level metadata, such as OFFSET for cardinality()/length() and NULL for
IS NULL, even when a covering data path is also needed later. The BE nested
column iterators consume those current-level metadata paths at the correct
iterator level without forwarding them to child iterators or incorrectly
switching mixed data reads into metadata-only mode.

This PR also handles predicate-only nested access paths. A complex iterator
should skip access-path setup only when both final access paths and predicate
access paths are empty. Otherwise predicate-only child or metadata paths may be
ignored in lazy read mode, causing predicates such as array element IS NULL to
evaluate on unread placeholder data.

branch-4.2 adaptations:
- gensrc/thrift/PaloInternalService.thrift: master uses id 226 for
  enable_prune_nested_column, but branch-4.2 already uses 226-228, so the
  option was moved to the next free id (229).
- segment_iterator.cpp keeps branch-4.2's _non_predicate_column_ids naming and
  drops the master-only _update_lsn_col_if_needed/_update_tso_col_if_needed
  calls from the ported hunk; branch-4.2 keeps its own
  common/compile_check_begin.h include in front of the new
  ScopedColumnIteratorReadPhase helper.
- column_reader.cpp keeps branch-4.2's dst->is_nullable()/zone_map.pass_all
  idioms and its AccessPathInfo/zone-map handling where the ported hunks only
  differed by master-side refactors.
- regression suites lambda_null_pruning and map_contains_arg_pruning do not
  exist on branch-4.2 and were not added.

Co-authored-by: Jerry Hu <hushenggang@selectdb.com>
Co-authored-by: Claude <noreply@anthropic.com>
…-named nested fields (apache#65805)

Problem Summary:

Nested-column pruning makes Doris read only the referenced parts of a
struct/map/array column. Two kinds of special meta access paths serve that
purpose: a terminal NULL component means the query only needs the null flag
(IS [NOT] NULL), and a terminal OFFSET component means only offsets are needed
(length/cardinality-style functions). The BE satisfies them in NULL_MAP_ONLY /
OFFSET_ONLY meta-read modes that skip the payload data entirely. While the
optimization itself is sound, the way meta paths were expressed on the wire and
handled on both sides had several correctness holes:

* Wire ambiguity. Every access path was sent as a DATA-typed list of string
  components, and NULL/OFFSET lived in the same list as real child names, so a
  struct field literally named OFFSET or NULL could be swallowed as metadata and
  its data silently pruned away, and there was no FE/BE version contract to
  evolve the encoding during rolling upgrades.
* FE null-map correctness. The old logic keyed on expression/slot nullability,
  so a physical NOT NULL dimension column of a LEFT JOIN received a [col, NULL]
  path and the BE aborted trying to read a null map from a NOT NULL column.
* FE pruning bookkeeping. NULL/OFFSET were detected by string suffix, so real
  fields with those names collided inside the pruning logic too.
* BE routing/read-mode logic. A terminal NULL/OFFSET was treated as current-level
  metadata at every container, meta-only modes could be entered even when a
  sibling DATA path still required payload reads, and Map descendant routing
  depended on physical key/value child column names while silently ignoring
  unknown selectors.

What this PR does:

* Versioned, type-selected access paths: adds an optional version to
  TColumnAccessPath (thrift + protobuf + FE/BE descriptor conversion;
  TCOLUMN_ACCESS_PATH_VERSION_LEGACY = 0, TCOLUMN_ACCESS_PATH_VERSION_TYPED = 1).
  In the typed format the path type is authoritative. New BEs still decode the
  legacy all-DATA encoding from old FEs, so the supported rolling upgrade is:
  upgrade all BEs first, then let FEs send typed paths.
* FE: marks NULL/OFFSET collector contexts as META; checks the physical column's
  nullability instead of slot nullability; falls back to plain data reads for
  variant sub-columns; keeps the exact meta path when a sibling data path is also
  read; and keys pruning on the path type instead of string suffixes.
* BE: validates path versions and type-selected payloads; partitions current
  data / current metadata / descendant routing with explicit per-container
  ownership; derives NULL_MAP_ONLY / OFFSET_ONLY only when no current or
  predicate data path requires payload; keeps sibling META paths explicit; routes
  Map children by the logical KEYS/VALUES selectors; rejects unrecognized Map
  selectors instead of silently pruning everything.
* Tests: BE ColumnReaderTest coverage for typed-vs-legacy encoding,
  metadata-vs-same-named-data disambiguation, meta-read-mode legality,
  sibling-data-path handling and Map selector validation; SlotDescriptor
  protobuf round-trip of versions and payloads; FE PruneNestedColumnTest cases;
  the nereids_rules_p0/column_pruning regression suites, plus the new
  left_join_not_null_column suite guarding the outer-join null-map crash.

branch-4.2 adaptations:
- branch-4.2 keeps the FE access-path model as thrift types instead of master's
  ColumnAccessPath/DescriptorToThriftConverter, so the typed version is stamped
  in NestedColumnPruning.buildColumnAccessPaths (the branch's single producer)
  and preserved by SlotTypeReplacer.replaceIcebergAccessPathToId. The
  master-only DescriptorToThriftConverter/DescriptorToThriftConverterTest files
  do not exist on this branch and were not added; the equivalent coverage lives
  in PruneNestedColumnTest, whose path()/metaPath() helpers now carry the typed
  version.
- Ported FE code was mapped onto this branch's names (TAccessPathType,
  TColumnAccessPath, isFunctionNullCheckPath) and keeps the segment-wise access
  path comparator introduced by the branch's nested-column-pruning port.
- Unit tests for lambda access paths and Map lazy-read-by-rowids that only exist
  on master (they come from PRs outside this chain) were not added, and the
  regression suites lambda_null_pruning / map_contains_arg_pruning do not exist
  on branch-4.2.

Co-authored-by: Hu Shenggang <hushenggang@selectdb.com>
@englefly
englefly requested a review from yiguolei as a code owner September 21, 2026 06:34
@englefly

Copy link
Copy Markdown
Contributor Author

run buildall

@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@englefly

Copy link
Copy Markdown
Contributor Author

/review

@hello-stephen

Copy link
Copy Markdown
Contributor

Cloud UT Coverage Report

Increment line coverage 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 73.27% (1826/2492)
Line Coverage 61.13% (32887/53800)
Region Coverage 48.69% (31134/63943)
Branch Coverage 51.61% (9661/18720)

@github-actions github-actions Bot 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.

Request changes: the exact reviewed head has four deterministic test/invariant failures. The sparse NULL-map rowid path leaves a nullable column with mismatched nested/null-map sizes; the new lazy-helper tests index an uninitialized block-position map; the outer-join test expects a DATA path where the collector emits the nullable field's META path; and the retained map SKIP test still assumes resize semantics after the implementation switched to appending placeholders.

I reviewed all 34 authoritative changed paths and the relevant FE/BE call chains, including typed DATA/META routing, thrift/protobuf and upgrade compatibility, map KEYS/VALUES expansion, two-phase lazy materialization, sparse/zero-row reads, Top-N type gating, profiles, and regression cleanup. No additional user focus was supplied.

Validation was static only: the review prompt prohibited builds and tests, so author/CI validation claims were not independently executed here.

ASSERT_EQ(2, null_map.size());
EXPECT_EQ(0, null_map[0]);
EXPECT_EQ(1, null_map[1]);
EXPECT_EQ(2, nullable_col.get_nested_column().size());

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.

[P1] Avoid appending the NULL-map defaults twice

This new test calls the NULL_MAP_ONLY rowid branch, which inserts count defaults into the nested column before the loop and then inserts total_read_count defaults again before returning. For these two rowids the null map has size 2 but the nested column has size 4, so this expectation fails (and the ColumnNullable is internally inconsistent). Please keep only one of those insertions, preferably after the actual read count is known.

auto iter = std::make_unique<SegmentIterator>(nullptr, _read_schema);
iter->_opts.tablet_schema = _tablet_schema;
iter->_opts.stats = &_stats;
iter->_support_lazy_read_pruned_columns.insert(0);

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.

[P1] Initialize the block-position map in this fixture

Both tests add cid 0 to _support_lazy_read_pruned_columns and call _read_lazy_pruned_columns() directly, but this fixture never initializes _schema_block_id_map. The helper immediately indexes _schema_block_id_map[0], while production normally populates it in _vec_init_lazy_materialization(), so these tests hit undefined behavior before checking rowids or phase restoration. Please initialize the map consistently with the one-column block (or drive the production initializer first).

+ " on driving_tbl.id = not_null_struct_tbl.id"
+ " where element_at(not_null_struct_tbl.s, 'f') is null",
// expect-contain: field is read (DATA path)
ImmutableList.of(path("s", "f")),

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.

[P1] Expect the nullable field's META path here

Although the enclosing s column is NOT NULL, SQL struct fields are constructed as nullable. For element_at(s, 'f') IS NULL, the collector therefore retains the META context and emits [s,f,NULL]; the physical-root guard only recognizes the direct one-component [NULL] path and does not turn this into DATA. Because this helper checks exact path equality, the expected [s,f] DATA path is absent and the new test fails. Please expect metaPath("s", "f", "NULL") here while keeping the negative assertion for root [s,NULL].

@@ -285,7 +3039,7 @@ TEST_F(ColumnReaderTest, MapReadByRowidsSkipReadingResizesDestination) {
MapFileColumnIterator map_iter(map_reader, std::move(null_iter), std::move(offsets_iter),
std::move(key_iter), std::move(val_iter));
map_iter.set_column_name("map_col");
map_iter.set_reading_flag(ColumnIterator::ReadingFlag::SKIP_READING);
map_iter.set_read_requirement(ColumnIterator::ReadRequirement::SKIP);

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.

[P1] Update this fixture for the new append semantics

This test's offsets column already contains one entry, so dst->size() is 1 before read_by_rowids(). With ReadRequirement::SKIP, the new placeholder helper calls insert_many_defaults(3), appending three map rows and leaving size 4; the unchanged assertion still expects the old resize(count) result of 3. Please start with an actually empty destination (or assert initial size plus count) if append semantics are intended, otherwise preserve explicit total-size resize behavior.

@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 79.06% (185/234) 🎉
Increment coverage report
Complete coverage report

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants