Repository navigation
fix(plugin-iceberg, native): Fix wrong results when reading row lineage columns - #28287
Joe-Abraham wants to merge 1 commit into
Conversation
Reviewer's GuideFixes Iceberg V3 row lineage filtering and projection by propagating scan output columns to pushdown, preserving stored lineage values in native reads, making file/split pruning conservative for inherited or incomplete metrics, and adding broad Java/native end-to-end coverage across pushdown modes. Sequence diagram for Iceberg row lineage filter pushdownsequenceDiagram
participant Planner
participant IcebergFilterPushdown
participant NativeConnector
participant VeloxReader
Planner->>IcebergFilterPushdown: pushdownFilter(filter, desiredColumns, currentLayoutHandle)
IcebergFilterPushdown->>NativeConnector: getConnectorPushdownFilterResult(desiredColumns)
NativeConnector->>VeloxReader: toVeloxTableHandle(requestedColumns)
VeloxReader-->>NativeConnector: Read stored lineage values
NativeConnector-->>Planner: Pushdown result with lineage projection
Flow diagram for conservative Iceberg lineage split pruningflowchart TD
A[Lineage constraint on _last_updated_sequence_number] --> B{Stored bounds available?}
B -->|Yes| C[Evaluate bounds and inherited data sequence number]
B -->|No| D{Lineage value counts indicate stored values?}
D -->|Yes| E[Keep file]
D -->|No| F{File may inherit data sequence number?}
F -->|Yes| E
F -->|No| G[Evaluate data sequence number and nullability]
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
d9e7dc0 to
3861c7f
Compare
3861c7f to
c3f7a88
Compare
A filter on `_row_id` or `_last_updated_sequence_number` returned the wrong rows when the engine pushed it into the scan, which Presto does with `iceberg.pushdown_filter_enabled`. For a three-row data file whose manifest entry carries `first_row_id = 100`:
SELECT c0, _row_id FROM t WHERE _row_id BETWEEN 101 AND 102
returned no rows instead of two. The reader evaluated the filter on the values stored in the file, and `_row_id` is synthesized after the read. A file that stores no lineage column reads as a null constant, and split pruning skipped it. A file an UPDATE rewrote stores the column only for the rows it carried over, and the reader kept or dropped rows by the stored nulls.
The split reader now applies these filters after it fills in the values, in the same pass as equality deletes. The filter stays on the scan spec; the new `ScanSpec::setFilterEnabled()` switches it off in the reader for a split that synthesizes the column, so dynamic filters still land on it. A lineage column the query filters on but does not select gets an appended reader output slot, the way unselected equality-delete columns already do. Appending a column with extraction pushdown on used to crash with `Trying to access non-existing child in RowVector`; the output vector is now allocated from the appended type.
With equality deletes, the split reader's `next()` now returns the number of rows it read, not the number left after the deletes, so `completedRows_` and `rawInputRows` include the deleted rows. Reader-side filters already count rows this way.
For a split that synthesizes a lineage column, a filter on it prunes no files or row groups. A dynamic filter that arrives after a split was preloaded does not reach that split when its lineage column is a constant; the join still applies it, so only performance is affected.
The reader now adds the row-number column whenever `_row_id` or `$target_table_row_id` is projected, and takes every file position from it. Previously it added the column only when a filter, random skip, or positional delete was present at split start, and otherwise counted positions on from the last batch. A dynamic filter on another column arriving mid-split made the reader drop rows while that count kept going, so `_row_id` came out wrong. The bug predates this PR, but a filter on `_row_id` used to force the column and no longer does. The cost is one extra row-number column for every scan that projects either lineage column.
Test plan: each lineage predicate runs as a scan subfield filter, as a remaining filter, and as a filter node above an unfiltered scan, and must return the same rows. `rowLineageDynamicFilterMidSplit` adds a dynamic filter on a data column between two batches of one split and checks that `_row_id` still matches the file positions.
End-to-end tests run on the Presto native worker in prestodb/presto#28287 (`TestIcebergV3RowLineage`), each with `iceberg.pushdown_filter_enabled` on and off. They cover lineage predicates on inherited, stored, and mixed stored-and-inherited values, before and after compaction, with deleted rows, combined with regular-column predicates, and on V2 tables and V2-to-V3 upgrades, plus split pruning.
Differential Revision: D123685869
Pull Request resolved: #18435
b9c06b2 to
1d7066a
Compare
1d7066a to
76b60a8
Compare
There was a problem hiding this comment.
Hey - I've found 2 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="presto-iceberg/src/main/java/com/facebook/presto/iceberg/IcebergUtil.java" line_range="877" />
<code_context>
+ if (valueCount != null && (nullCount == null || nullCount < valueCount)) {
+ return true;
+ }
return domain.includesNullableValue(dataSequenceNumber);
}
</code_context>
<issue_to_address>
**Stored lineage rows disappear**
When a V3 file physically stores `_last_updated_sequence_number` values but omits that column’s bounds and counts, `lastUpdatedSequenceNumberMatches` falls back to `dataSequenceNumber` and prunes files whose stored values match the filter, so query results omit valid rows.
Treat missing lineage metrics as unknown and avoid pruning on `dataSequenceNumber` when stored lineage values may be present.
</issue_to_address>
### Comment 2
<location path="presto-iceberg/src/main/java/com/facebook/presto/iceberg/IcebergPageSourceProvider.java" line_range="478-479" />
<code_context>
ImmutableMap.Builder<ColumnDescriptor, Domain> predicate = ImmutableMap.builder();
effectivePredicate.getDomains().get().forEach((columnHandle, domain) -> {
+ // Stored row lineage values exclude inherited ones, so their statistics cannot prune.
+ if (columnHandle.isRowIdColumn() || columnHandle.isLastUpdatedSequenceNumberColumn()) {
+ return;
+ }
String baseType = columnHandle.getType().getTypeSignature().getBase();
</code_context>
<issue_to_address>
**Inherited row IDs get pruned**
When a file contains both stored row IDs and null row IDs that inherit positional values, and the predicate matches an inherited value outside the stored values’ bounds, for a mixed-lineage file, `IcebergSplitManager` still applies the row-ID constraint through Iceberg’s ordinary metrics evaluator before the page source runs. That evaluator sees only the stored row-ID bounds, so it drops the file when those bounds exclude a matching inherited ID, and the query omits valid rows.
Remove row-ID constraints from the Iceberg scan filter and evaluate them with lineage-aware split logic, or otherwise make scan pruning account for inherited row IDs.
</issue_to_address>There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
Review effort: Lite
Findings: 1
Open (3)
Constructing astd::shared_ptrdirectly fromtoVeloxColumnHandle(&entry, typeParser)is risky… · NewrequestedColumnsis now alwaysOptional.of(...), which can change semantics vs the previous… · New This manualtry/finallyclose pattern can mask the original exception ifwriter.close()throws… · New
Resolved since last review (1)
76b60a8 to
8c05d49
Compare
There was a problem hiding this comment.
Hey - I've found 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="presto-iceberg/src/main/java/com/facebook/presto/iceberg/IcebergUtil.java" line_range="874-877" />
<code_context>
+ if (getMetric(file.lowerBounds(), fieldId) != null && getMetric(file.upperBounds(), fieldId) != null) {
+ return evaluator.eval(file) || (mayInherit && domain.includesNullableValue(dataSequenceNumber));
+ }
+ if (valueCount != null && (nullCount == null || nullCount < valueCount)) {
+ return true;
+ }
return domain.includesNullableValue(dataSequenceNumber);
}
</code_context>
<issue_to_address>
**Matching rows disappear**
When a data file stores lineage values but has no bounds or value-count metric, and the predicate matches a stored value but not the file’s data sequence number, `lastUpdatedSequenceNumberMatches` falls back to matching only the file’s data sequence number, prunes the file, and omits matching rows from query results.
Treat files with missing lineage bounds and value counts as potentially matching stored values instead of relying only on the data sequence number.
</issue_to_address>e326b34 to
718c30e
Compare
There was a problem hiding this comment.
Hey - I've found 2 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="presto-iceberg/src/main/java/com/facebook/presto/iceberg/IcebergUtil.java" line_range="880-881" />
<code_context>
+ if (valueCount != null && (nullCount == null || nullCount < valueCount)) {
+ return true;
+ }
+ if (valueCount == null && !appendSequenceNumbers.get().contains(file.fileSequenceNumber())) {
+ return true;
+ }
return domain.includesNullableValue(dataSequenceNumber);
</code_context>
<issue_to_address>
**Matching rows are pruned**
When an APPEND file physically stores `_last_updated_sequence_number` values but lacks value-count metrics or bounds, and a predicate matches a stored value but not the file's data sequence number, `lastUpdatedSequenceNumberMatches` treats the missing metrics as proof that no values are stored, then tests only `dataSequenceNumber`; split pruning drops the file and matching rows disappear.
Treat absent metrics as unknown for APPEND files and avoid pruning based only on `dataSequenceNumber` when stored lineage values may exist.
</issue_to_address>
### Comment 2
<location path="presto-hive-common/src/main/java/com/facebook/presto/hive/rule/BaseSubfieldExtractionRewriter.java" line_range="185" />
<code_context>
metadata,
handle.getConnectorHandle(),
replacedExpression,
+ ImmutableSet.copyOf(tableScan.getAssignments().values()),
handle.getLayout());
</code_context>
<issue_to_address>
**Projection-only lineage columns stay missing**
When a table-scan rewrite has a current layout whose `requestedColumns` is empty or stale and the filter is `TRUE_CONSTANT`, `pushdownFilter` returns the existing layout on its `TRUE_CONSTANT` fast path before forwarding `desiredColumns`; the native connector then has no requested lineage columns to add to its data columns, so projection-only queries can return inherited values instead of stored values.
Update the existing layout with the scan's `desiredColumns` on the true-filter fast path before returning it.
</issue_to_address>|
Thanks @Joe-Abraham Suggestions
Nits
Questions
Testing Recommendations
|
|
Thanks @nmahadevuni.
|
| if (kRowLineageColumnNames.count(icebergColumn.columnIdentity.name) && | ||
| columnNames.emplace(icebergColumn.columnIdentity.name).second) { | ||
| columnHandles.emplace_back( | ||
| std::dynamic_pointer_cast< |
There was a problem hiding this comment.
use dynamic_cast and VELOX_CHECK_NOT_NULL, like the one at line 637
|
|
||
| using connector::hive::iceberg::IcebergMetadataColumn; | ||
| auto rowIdColumn = std::make_shared<protocol::iceberg::IcebergColumnHandle>(); | ||
| rowIdColumn->columnIdentity.id = IcebergMetadataColumn::kRowId; |
There was a problem hiding this comment.
lets add _last_updated_sequence_number test too. And also case where lineage column is in projection and filter.
There was a problem hiding this comment.
Thanks @Joe-Abraham. Moving the native side to the TableScanNode assignments is much cleaner than threading desiredColumns through hive-common. I checked the pinned Velox submodule (3f23f2386) and it already contains facebookincubator/velox#18435 (6358057b8, "Apply row lineage filters after synthesis"), so this PR has no hidden Velox dependency.
Just some test simplification suggestions.
1. [Important] PR description and commits are out of date
- The Description and Impact sections still say the scan columns are passed to
getConnectorPushdownFilterResultasdesiredColumns, and thatBaseSubfieldExtractionRewriter#getConnectorPushdownFilterResultgains a parameter. That change was reverted in 4613aa6. Please describe the newtoVeloxTableHandle(tableHandle, assignments, ...)overload instead. - The Test Plan says
TestIcebergRowLineagepasses 18/18. The test list has changed since then, so please refresh that count. - The branch has 4 commits, including "Review comments" and a refactor that reverts part of the first commit. Please squash them into one commit with the PR title before merge.
2. [Important] Pruning on _last_updated_sequence_number now depends on snapshot retention
IcebergUtil.java:885: a data file that does not physically contain the lineage column has no metrics for it (valueCount == null). That covers every file a plain append writes, whatever the metrics mode. Before this PR those files were pruned by dataSequenceNumber. Now they are pruned only if the snapshot that added them is a live APPEND. So these are always scanned:
- every appended file once its snapshot has been expired. That happens on any table that runs
expire_snapshots, which in practice is most of the files in a production table; - files added by an
overwritecommit that writes no lineage, for example Presto's own UPDATE/MERGE (RowDelta with deletes); - files added by a
replacecommit from a compaction that does not preserve lineage. That includes Presto's ownrewrite_data_files: as theTestIcebergRowLineageBasejavadoc notes, Presto cannot write stored lineage values, so every Presto-compacted file falls into this bucket.
The result is still correct, and testFileFromExpiredAppendIsNotPruned locks in the conservative behaviour on purpose. Still, this is a real regression for the main use case of this predicate (incremental reads like WHERE _last_updated_sequence_number > N on a long-lived table). Could you:
- say in the code comment that pruning stops for files whose APPEND snapshot has expired, so nobody reads that as a bug later; and
- open a follow-up issue for a stronger signal. One option: the field IDs present in the file. The coordinator cannot see them without reading the footer, but the worker can skip a row group or the whole file once it knows the column is absent and the inherited
dataSequenceNumberdoes not match.
3. [Suggestion] Add IcebergColumnHandle.isRowLineageColumn()
isRowIdColumn() || isLastUpdatedSequenceNumberColumn() now shows up in three places: IcebergUtil.java:807, IcebergPageSourceProvider.java:478 and IcebergPageSourceProvider.java:631-632. A single helper next to isRowIdColumn() would keep these in sync if another lineage column is ever added, matching what kRowLineageColumnNames already does on the C++ side.
4. [Suggestion] Factor out the column-handle conversion in toVeloxTableHandle
IcebergPrestoToVeloxConnector.cpp:710-725: the new loop repeats the "dedupe by name, toVeloxColumnHandle, shared_ptr, dynamic_pointer_cast" block used by the partition-column and predicate-column loops above it. A small lambda such as addColumnHandle(const protocol::iceberg::IcebergColumnHandle&) would turn all three loops into two or three lines each.
5. [Suggestion] Unit test for lastUpdatedSequenceNumberMatches
Every branch of this function is only reached through end-to-end tests that build files with the Iceberg API, and some branches are not reached at all:
- bounds present but
nullCount == null(mayHaveInheritedValuestrue from a missing count); valueCountpresent,nullCount == null;valueCount == nullCount(all stored values null, so the file falls back todataSequenceNumber);fileSequenceNumber() == null.
A table-driven test in TestIcebergUtil with a stub DataFile (or DataFiles.builder(...).withMetrics(...)) would cover all of them in milliseconds and would document the decision table better than the comment does.
6. [Nit] C++ test setup is copied three times
icebergProjectedRowLineageColumns, icebergAssignmentsRejectNonIcebergColumn and icebergFilteredAndProjectedRowLineageColumn each rebuild the IcebergTableHandle, the same tableSchemaJson literal (already used in icebergTableSchemaFieldIds), the TableHandle and a single-entry assignments map. A helper like makeIcebergTableHandle(layout) plus a lineage column-handle factory would cut about 60 lines. Also, icebergFilteredAndProjectedRowLineageColumn sits after the anonymous namespace near line 431, away from the other two. Moving it next to them, or moving createSingleRangeDomain up, would keep the lineage tests together.
7. [Nit] /*assignments=*/{} relies on name hiding
IcebergPrestoToVeloxConnector.cpp:666-667: {} resolves to the protocol::Map overload only because IcebergPrestoToVeloxConnector hides the base IndexHandle overload. If Iceberg ever overrides the index overload, this call becomes ambiguous. Passing a named empty map (or protocol::Map<...>{}) avoids that.
Questions
- Since Presto's
rewrite_data_filesdoes not carry stored lineage into the compacted files,_row_idand_last_updated_sequence_numberreset after a Presto compaction. That is out of scope here. Is there an issue tracking it? - Was
TestIcebergV3RowLineagerun on ORC withpushdown_filter_enabled=true? The PR body only says the Java suite passed.
b012331 to
3652ccc
Compare
c245286 to
416edbb
Compare
|
AI session: The AI reviewer completed the code review and posted the findings in the pull request review timeline. |
prestodb-ci
left a comment
There was a problem hiding this comment.
No actionable findings found.
|
@sourcery-ai review |
|
Sorry @Joe-Abraham, you've used your own review budget of 250,000 diff characters for the last 7 days. You can request another review in 32 minutes by commenting |
416edbb to
f73ddfb
Compare
steveburnett
left a comment
There was a problem hiding this comment.
LGTM! (docs)
Pull branch, local doc build, looks good. Thanks for the doc!


Description
A row lineage column (
_row_id,_last_updated_sequence_number) iseither stored in the data file or, when the stored value is null,
inherited:
_row_idfrom the file'sfirst_row_idplus the rowposition,
_last_updated_sequence_numberfrom the file's data sequencenumber. A file rewritten by compaction or by a row-preserving
UPDATE/MERGE can hold both kinds of rows, and file statistics describe
only the stored values.
_last_updated_sequence_numberkeeps a file wheneither its stored-value bounds or its data sequence number can match.
A file without lineage bounds is pruned when the predicate excludes
every value up to its file sequence number, since inherited values are
its data sequence number and stored values are copied from rows of
earlier commits. Otherwise it is kept when its counts show stored
values. A file with no lineage metrics is pruned on its data sequence
number only while the APPEND snapshot that added it is retained,
since appended files store no lineage values.
row group and stripe statistics.
lineage columns from the predicate before planning delete files, as
the data table statistics already do, since Iceberg cannot bind them
against the table schema.
that appear only in the scan's assignments to the table handle's data
columns, so the reader returns their stored values. A new
PrestoToVeloxConnector::toVeloxTableHandleoverload receives theTableScanNode assignments; the default implementation ignores them.
The Velox side of the native fix, applying row lineage filters after
inherited values are synthesized, is in facebookincubator/velox#18435.
The pinned Velox commit already includes it.
Motivation and Context
Queries filtering on a row lineage column could silently drop rows:
evaluated only the stored-value bounds, and Parquet and ORC
statistics saw only stored values, so rows with inherited values
were skipped.
countsornonemetrics for thelineage columns was pruned by its own, newer data sequence number,
although its rows store older values.
On the native worker, a query that projected a row lineage column
without filtering on it returned inherited values for rows with stored
values, so it disagreed with the same query filtered on that column.
Impact
No public API or configuration changes. Queries filtering on row
lineage columns return correct results. Row lineage predicates no
longer prune Parquet row groups or ORC stripes. A file without lineage
metrics whose APPEND snapshot has expired is pruned only when the
predicate excludes every value up to the file's sequence number, as in
_last_updated_sequence_number > N, so other predicates can read moredata.
Pruning a file without lineage metrics on its data sequence number
assumes that writers store lineage values only in commits that rewrite
rows, which Iceberg reports as OVERWRITE or REPLACE rather than APPEND.
The Iceberg spec does not guarantee this. Pruning on the file sequence
number follows from the spec's rules for copying lineage values.
Before this change, statistics for the equality delete scan failed to
compute when a query filtered on a row lineage column. With
ignore_stats_calculator_failuresat its default oftrue, suchqueries were planned without those statistics rather than failing.
Test Plan
TestIcebergRowLineageBase: moved the predicate tests fromTestIcebergRowLineageinto the base class and parameterized them bypushdown_filter_enabled. Added cases for files mixing stored andinherited values (Parquet and ORC),
_row_idpredicates on stored andinherited values,
IS NULL, deleted rows, lineage predicates combinedwith regular columns, compacted files with
countsandnonelineagemetrics, appended files without metrics, files whose APPEND snapshot
has expired (pruned only by predicates above their file sequence
number), and projection-only versus filtered queries.
TestIcebergUtil#testLastUpdatedSequenceNumberSplitPruning: splitpruning across combinations of lineage bounds, value and null counts,
first_row_id, file sequence number, and APPEND snapshot retention.PrestoToVeloxConnectorTest: projected row lineage columns reach thedata columns with their field IDs without becoming filter columns, and
a column in both a predicate and the assignments is added once.
TestIcebergV3RowLineage(presto-native-tests): runs the base suiteon native workers with
pushdown_filter_enabledtrue and false, andcompares each query with the Java workers.
Contributor checklist
Release Notes
Summary by Sourcery
Correct Iceberg row lineage reads and pruning across Java and native execution paths.
Bug Fixes:
Enhancements:
Documentation:
Tests: