Skip to content

Spark 4.1: Add MergingSortedRowDataReader for k-way merge of sorted files - #14948

Open
anuragmantri wants to merge 18 commits into
apache:mainfrom
anuragmantri:supports-report-ordering
Open

Spark 4.1: Add MergingSortedRowDataReader for k-way merge of sorted files#14948
anuragmantri wants to merge 18 commits into
apache:mainfrom
anuragmantri:supports-report-ordering

Conversation

@anuragmantri

@anuragmantri anuragmantri commented Dec 31, 2025

Copy link
Copy Markdown
Collaborator

This is first PR to report ordering to Spark.

This PR adds MergingSortedRowDataReader, a PartitionReader that merges rows from multiple sorted data files into a single sorted stream using a k-way heap merge (SortedMerge).

This reader is not wired up yet. A follow-up PR will integrate it with the SupportsReportOrdering DSv2 API to enable Spark's sort elimination optimization.

How it works:

  1. Each file in a task group is opened as a separate RowDataReader
  2. Sort key columns absent from Spark's projection are temporarily added to the read schema so the comparator can access them
  3. SortOrderComparators.forSchema() with InternalRowWrapper handles all transform types (identity, bucket, truncate), ASC/DESC directions, and null ordering
  4. Extra columns are stripped via ProjectingInternalRow before returning rows to Spark

Constraints:

  • Nested struct sort keys are not supported (to be handled in a follow-up)
  • Row-based only (not compatible with vectorized reads)

AI Usage: I used Claude Opus 4.6 for code generation and writing tests. I manually reviewed the generated code.

@anuragmantri anuragmantri changed the title [WIP] Spark 4.0: Implement SupportsReportOrdering DSv2 API Spark 4.0: Implement SupportsReportOrdering DSv2 API Dec 31, 2025
@anuragmantri
anuragmantri force-pushed the supports-report-ordering branch from cc08ff2 to b4fde94 Compare January 21, 2026 22:08
@anuragmantri anuragmantri changed the title Spark 4.0: Implement SupportsReportOrdering DSv2 API Spark 4.1: Implement SupportsReportOrdering DSv2 API Jan 21, 2026
@anuragmantri anuragmantri changed the title Spark 4.1: Implement SupportsReportOrdering DSv2 API [WIP] Spark 4.1: Implement SupportsReportOrdering DSv2 API Jan 21, 2026
@anuragmantri

Copy link
Copy Markdown
Collaborator Author

Moved the changes to Spark 4.1 since it is now the latest version. Marked this PR as WIP as there is a prerequisite PR #14683 that is also in review.

@peter-toth

peter-toth commented Feb 19, 2026

Copy link
Copy Markdown

My concern from Spark PoV is that unnecessary partition grouping can cause performance degradations. SPARK-55092 is a ticket about the problem and apache/spark#53859 / apache/spark#54330 PRs try to fix the problem.

If this PR disables bin packing then the above PRs won't be able to fix the issue.

  1. Bin-packing of file scan tasks is disabled when ordering is required since Spark will discard ordering if multiple input partitions exist with the same grouping key.

So I would suggest keeping bin packing and reporting sort order for those packed partitions (i.e. the partitions might not be unique by key, but they are locally sorted), and when partition grouping is actually needed then Spark should merge the sorted partitions with the same key using k-way merge.

@peter-toth

peter-toth commented Feb 19, 2026

Copy link
Copy Markdown

As we discussed offline, a long term (after apache/spark#54330) solution could be to improve the new GroupPartitionsExec operator to not only coalesce partitions with the same key, but k-way merge them to keep their sorted order.

@github-actions

Copy link
Copy Markdown

This pull request has been marked as stale due to 30 days of inactivity. It will be closed in 1 week if no further activity occurs. If you think that’s incorrect or this pull request requires a review, please simply write any comment. If closed, you can revive the PR at any time and @mention a reviewer or discuss it on the dev@iceberg.apache.org list. Thank you for your contributions.

@github-actions github-actions Bot added the stale label Mar 22, 2026
@anuragmantri

Copy link
Copy Markdown
Collaborator Author

This PR is not stale. We are waiting waiting for the pre-requisite PR to be merged. I will update this PR after that one is merged.

@github-actions github-actions Bot removed the stale label Mar 24, 2026
@anuragmantri
anuragmantri force-pushed the supports-report-ordering branch from b4fde94 to dbf3ea9 Compare March 31, 2026 00:47
@anuragmantri anuragmantri changed the title [WIP] Spark 4.1: Implement SupportsReportOrdering DSv2 API Spark 4.1: Implement SupportsReportOrdering DSv2 API Mar 31, 2026
@anuragmantri

Copy link
Copy Markdown
Collaborator Author

I rebased the PR after #15150. This is ready for review now.

@RussellSpitzer @aokolnychyi @szehon-ho - Could you take a look please?

@anuragmantri

Copy link
Copy Markdown
Collaborator Author

As we discussed offline, a long term (after apache/spark#54330) solution could be to improve the new GroupPartitionsExec operator to not only coalesce partitions with the same key, but k-way merge them to keep their sorted order.

Thanks @peter-toth , this makes sense. I think this PR is still needed and would still be valuable for tables with decently sized partitions. Since it's gated by a flag, I think it's safe to implement.

@peter-toth

peter-toth commented Mar 31, 2026

Copy link
Copy Markdown

As we discussed offline, a long term (after apache/spark#54330) solution could be to improve the new GroupPartitionsExec operator to not only coalesce partitions with the same key, but k-way merge them to keep their sorted order.

Thanks @peter-toth , this makes sense. I think this PR is still needed and would still be valuable for tables with decently sized partitions. Since it's gated by a flag, I think it's safe to implement.

Absolutely.
FYI apache/spark#54330 has been merged. apache/spark#55116 will do the Spark side k-way merge to keep full ordering, but it requires this PR to report ordering and provide decently sized partitions.

@Hugo-WB

Hugo-WB commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Excited for this! Out of curiosity would this fix/help rewrite_data_files of sorted files/partitions?

@anuragmantri
anuragmantri force-pushed the supports-report-ordering branch from a6f0205 to 51b4150 Compare May 2, 2026 01:33
@anuragmantri

Copy link
Copy Markdown
Collaborator Author

Out of curiosity would this fix/help rewrite_data_files of sorted files/partitions?

@Hugo-WB - This is only a read side optimization for partitioned tables using the SparkScan. Rewrite data files uses a different staged file scan which bypasses this for more control of the shuffling and sorting.

I do have another change that uses the same K-way merge logic to do compactions. I will submit a PR for that soon.

@anuragmantri

Copy link
Copy Markdown
Collaborator Author

@Hugo-WB - #16305 is a PR that leverages sort metadata for rewriting data.

@Hugo-WB

Hugo-WB commented May 13, 2026

Copy link
Copy Markdown
Contributor

amazing! Tysm. Looking forward to these changes! we are compacting a lot of sorted files and keen to eliminate shuffles where possible.

continue;
}

FileScanTask fileTask = (FileScanTask) task;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

fileTask can be replaced with pattern variable

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done.

}

/** Returns whether sort ordering was reported for this batch's scan. */
private boolean isOrderingEnabled() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why do we need this private method?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Inlined it with orderingEnabled boolean.

* enabled at the table level (validated by {@link #isOrderingEnabled()}, multiple files in the
* group, and all tasks being {@link FileScanTask}s.
*/
private boolean shouldUseMergingSortedReader(ScanTaskGroup<?> taskGroup) {

@manuzhang manuzhang May 15, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can cache the result instead of caching this every time.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You are right. We are anyway disabling bin packing so a task group is 1:1 with an input partition. I refactored this check for all readers anyGroupNeedsMergingReader().


MergingSortedRowDataReader(
Table table,
org.apache.iceberg.io.FileIO io,

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: can we import FileIO? I don't see a existing class conflict

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done.

}
}
Preconditions.checkState(
found, "Projection field id=%s not found in merge read schema — this is a bug", fieldId);

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.

can probably drop — this is a bug, I don't think we usually include such in precondition check?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done.

boolean caseSensitive,
boolean cacheDeleteFilesOnExecutors) {
SortOrder sortOrder = table.sortOrder();

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, remove the extra newline

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done

* A {@link PartitionReader} that reads multiple sorted files and merges them into a single sorted
* stream using a k-way heap merge ({@link SortedMerge}).
*
* <p>This reader is used when {@code preserve-data-ordering} is enabled and the task group contains

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.

I think this refer to 2nd part of the PR in https://github.com/apache/iceberg/pull/16750/changes? Probably can follow up with the actual link to the table property. WDYT?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, removed the reference.

Comment on lines +276 to +291
List<Types.NestedField> mergeColumns = mergeSchema.columns();
List<Object> positions = Lists.newArrayListWithCapacity(projection.columns().size());

for (int i = 0; i < projection.columns().size(); i++) {
int fieldId = projection.columns().get(i).fieldId();
boolean found = false;
for (int j = 0; j < mergeColumns.size(); j++) {
if (mergeColumns.get(j).fieldId() == fieldId) {
positions.add(j);
found = true;
break;
}
}
Preconditions.checkState(
found, "Projection field id=%s not found in merge read schema — this is a bug", fieldId);
}

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.

instead of manually find projection with mergedSchema with O(n·m) nested scan, I think we can probably leverage existing utility?

  List<Object> positions = Lists.newArrayListWithCapacity(projection.columns().size());
  for (Types.NestedField column : projection.columns()) {
    Accessor<StructLike> accessor = mergeSchema.accessorForField(column.fieldId());
    Preconditions.checkState(
        accessor != null,
        "Projection field id=%s not found in merge read schema",
        column.fieldId());
    positions.add(Accessors.toPosition(accessor));
  }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good idea. Done.

Comment on lines +196 to +204
@Override
public void close() {
// No-op. The RowDataReaders are owned by the enclosing CloseableGroup
// (resources) and closed exactly once from close(). SortedMerge cannot be the
// sole owner because it filters out empty iterators (for example a file whose
// rows are all removed by deletes), so those readers would never be closed
// through the merge. Closing here as well would double-close the readers that
// SortedMerge does drain.
}

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.

do we still need this override?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Just kept it for the comment. I don't have a preference though.

Comment on lines +65 to +68
* <p>Sort key columns absent from the requested projection are temporarily added to the read schema
* so that {@link SortOrderComparators} can access them during the merge. The extra columns are
* stripped from each row before it is returned to Spark.
*/

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.

I feel this is implementation details, do we really need those?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I clean it up a bit.

Comment on lines +254 to +255
* directions, and null ordering. The two {@link InternalRowWrapper} instances are allocated once
* and reused — {@code wrap()} just updates an internal reference.

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.

I think last sentence is also a bit explaining the details, maybe just mention reuse of InternalRowWrapper for the purpose of comparator.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I changed to have less details.

@nssalian nssalian added this to the Iceberg 1.12.0 milestone Jul 29, 2026
@nssalian
nssalian requested a review from huaxingao July 29, 2026 21:50
@anuragmantri
anuragmantri force-pushed the supports-report-ordering branch from 7110906 to cb987a4 Compare July 31, 2026 06:18

@anuragmantri anuragmantri left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks @dramaticlly. I addressed your comments and made some improvements in the latest revision.

Comment on lines +196 to +204
@Override
public void close() {
// No-op. The RowDataReaders are owned by the enclosing CloseableGroup
// (resources) and closed exactly once from close(). SortedMerge cannot be the
// sole owner because it filters out empty iterators (for example a file whose
// rows are all removed by deletes), so those readers would never be closed
// through the merge. Closing here as well would double-close the readers that
// SortedMerge does drain.
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Just kept it for the comment. I don't have a preference though.

Comment on lines +254 to +255
* directions, and null ordering. The two {@link InternalRowWrapper} instances are allocated once
* and reused — {@code wrap()} just updates an internal reference.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I changed to have less details.

Comment on lines +65 to +68
* <p>Sort key columns absent from the requested projection are temporarily added to the read schema
* so that {@link SortOrderComparators} can access them during the merge. The extra columns are
* stripped from each row before it is returned to Spark.
*/

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I clean it up a bit.

Comment on lines +276 to +291
List<Types.NestedField> mergeColumns = mergeSchema.columns();
List<Object> positions = Lists.newArrayListWithCapacity(projection.columns().size());

for (int i = 0; i < projection.columns().size(); i++) {
int fieldId = projection.columns().get(i).fieldId();
boolean found = false;
for (int j = 0; j < mergeColumns.size(); j++) {
if (mergeColumns.get(j).fieldId() == fieldId) {
positions.add(j);
found = true;
break;
}
}
Preconditions.checkState(
found, "Projection field id=%s not found in merge read schema — this is a bug", fieldId);
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good idea. Done.

* A {@link PartitionReader} that reads multiple sorted files and merges them into a single sorted
* stream using a k-way heap merge ({@link SortedMerge}).
*
* <p>This reader is used when {@code preserve-data-ordering} is enabled and the task group contains

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, removed the reference.

boolean caseSensitive,
boolean cacheDeleteFilesOnExecutors) {
SortOrder sortOrder = table.sortOrder();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done

}
}
Preconditions.checkState(
found, "Projection field id=%s not found in merge read schema — this is a bug", fieldId);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done.


MergingSortedRowDataReader(
Table table,
org.apache.iceberg.io.FileIO io,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done.

fieldId,
table.name());
Preconditions.checkArgument(
TypeUtil.ancestorFields(tableSchema, fieldId).isEmpty(),

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.

ancestorFields is a full schema walk per sort field, I think if we can just check with tableSchema.asStruct().field(fieldId) != null is enough for verifying top level (non-nested) field here. As it depends on the sortKey

  • if this is already projected we can read fine even nested.
  • else I think we need fails as we don't use join with nested sortKey

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done.

Comment on lines +274 to +276
if (projection.columns().size() == mergeSchema.columns().size()) {
return 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.

looks like column count check is a proxy to evaluate whether the projection is the same

Suggested change
if (projection.columns().size() == mergeSchema.columns().size()) {
return null;
}
if (projection.sameSchema(mergeSchema)) {
return null;
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, the same. Done.

Comment on lines +261 to +263
StructType sparkSchema = SparkSchemaUtil.convert(mergeReadSchema);
Comparator<StructLike> keyComparator =
SortOrderComparators.forSchema(mergeReadSchema, sortOrder);

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.

From Claude, it seems for UUIDType we might have different ordering, the merge comparator use iceberg ordering

.put(Types.UUIDType.get(), Comparator.naturalOrder())
while files written by spark might be sorted by spark's ordering.
case UUID:
// use String
return StringType$.MODULE$;

See example repro in https://gist.github.com/dramaticlly/c57b7382f4e57cc9bf90f4b9e8366e9a

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.

Found a related issue in #14216, I think at minimal we shall exclude the sortKey with UUID type.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch, I looked at this more. In the merging reader, we are comparing the output of the transforms. So only impacted case seems to be identity(UUID). I added a pre-condition to ensure the result of the transform cannot be UUID. But the actual check should be upstream when creating the MergingSortedRowReader. I will add that check in the wiring PR #16750

Schema mergeReadSchema = mergeReadSchema(projection, sortOrder, table);
this.projectingRow = buildProjectingRow(projection, mergeReadSchema);

this.resources = new CloseableGroup();

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.

do we want to enable this.closeableGroup.setSuppressCloseFailure(true)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done.

}

@Test
void mergeWithFileFullyRemovedByDeletes() 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.

can we add a 3 files here so that file1 is fully removed and file2 and file3 still enter the merge code path after apply the row to be positional deleted?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added a test

anuragmantri and others added 18 commits August 6, 2026 14:57
…g ordering decision to SparkRowReaderFactory
Revert integration code (SortOrderAnalyzer, SparkPartitioningAwareScan,
SparkBatch wiring, config flags) to a follow-up PR. Keep only the k-way
merge reader and its direct unit tests.
Fix NPE on files with a null sort order ID, avoid re-setting
InputFileBlockHolder on every row, use TypeUtil.ancestorFields to
distinguish nested sort keys from missing ones, and resolve projection
positions via Accessors. Add tests for the preconditions, sort order
evolution, and a file fully removed by deletes.
@anuragmantri
anuragmantri force-pushed the supports-report-ordering branch from cb987a4 to d21c514 Compare August 6, 2026 21:58
@anuragmantri

Copy link
Copy Markdown
Collaborator Author

This is ready for another round of review.

@szehon-ho @huaxingao @RussellSpitzer - It will be good to include this in the 1.12 release as this has been under review for a while now. Could you all also take a look, please?

@anuragmantri

Copy link
Copy Markdown
Collaborator Author

@manuzhang - Just FYI for your Spark 4.2 work. This PR is relevant for Spark 4.2 as well. However the plumbing PR #16750 will be slightly less restrictive as we can keep the bin-packing on Iceberg and spark can do another k-way merge.


@Test
void mergeDescendingOrder() throws IOException {
catalog.dropTable(TableIdentifier.of("default", "test_merging_reader"));

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.

do we need this? I thought we already dropped in afterEach ? same for the rest of 5 repetition below

Comment on lines +569 to +577
catalog.dropTable(TableIdentifier.of("default", "test_merging_reader"));

Schema nestedSchema =
new Schema(
required(1, "id", Types.IntegerType.get()),
required(
2, "location", Types.StructType.of(required(3, "city", Types.StringType.get()))));

table = catalog.createTable(TableIdentifier.of("default", "test_merging_reader"), nestedSchema);

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: maybe update schema is easier than drop and recreate?

hasNext();
}
advanced = false;
return new TaggedRow(reader.get().copy(), block);

@dramaticlly dramaticlly Aug 13, 2026

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.

Actually I think there might be more problem as this is not a deep copy for Parquet collections of complex elements. We might need toUnsafe.apply(reader.get()).copy() via UnsafeProjection over the merge schema.

I think primitive type is generally ok, but for array or map of struct it might lead to row corruption. Might worth a try for this UT https://gist.github.com/dramaticlly/f91eb9d4186ae4b3101834bd4c5d20dd

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants