Skip to content

[IcebergIO] Support sorted writes and RANGE distribution mode (#38406) - #38406

Closed
atognolag wants to merge 21 commits into
apache:masterfrom
atognolag:feature/sorted-icebergIO
Closed

atognolag wants to merge 21 commits into
apache:masterfrom
atognolag:feature/sorted-icebergIO

Conversation

@atognolag

@atognolag atognolag commented May 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds end-to-end support for sorting records on write according to an Iceberg table's SortOrder (.withSortedWrites() / Managed IO sort_on_write: true) as well as DistributionMode.RANGE (.withDistributionMode(DistributionMode.RANGE) / Managed IO distribution_mode: "RANGE") across both Batch and Streaming pipelines:

  1. Order-Preserving Binary Key Encoding & External Spill-to-Disk Sorting (IcebergRowSorter):

    • Encodes composite Iceberg SortOrder keys into lexicographically comparable byte[] arrays supporting ASC/DESC, NULLS_FIRST/NULLS_LAST, all Iceberg primitive/logical types (BOOLEAN, INTEGER, LONG, FLOAT, DOUBLE, DECIMAL, STRING, UUID, FIXED, BINARY, DATE, TIME, TIMESTAMP, TIMESTAMPTZ, EnumerationType), and non-identity sort transforms (month, day, hour, year, bucket, truncate).
    • Uses BufferedExternalSorter (NATIVE) with bounded per-instance memory (64 MB for single-partition grouped writes, 16 MB per destination in multi-destination bundle writes) to spill to local disk when a partition or bundle exceeds memory.
  2. Zero-Shuffle Local Bundle Sorting for DistributionMode.NONE + withSortedWrites() (RecordWriterManager):

    • Buffers incoming rows per bundle in IcebergRowSorter keyed by (partitionPath, sortKey) without opening any RecordWriters during @ProcessElement.
    • At @FinishBundle, flushes sorted rows one partition at a time—keeping only a single active RecordWriter open at any instant—eliminating any network shuffle for DistributionMode.NONE in both Batch and Streaming.
  3. Dynamic Quantile Sharding for DistributionMode.RANGE (AssignDestinationsAndPartitions):

    • Supports both a custom withDistributionFunction(SerializableFunction<Row, Integer>) and automatic quantile sharding in Batch when distributionFunction is omitted.
    • Uses Efraimidis–Spirakis (A-Res) weighted reservoir sampling without replacement (2,048 samples per (destination, partition)) combined with estimated partition byte volume to compute balanced quantile split boundaries sized to write.target-file-size-bytes (default 512 MB).
    • Avoids materializing byte[] sort keys across the stage barrier for un-sampled rows; only encodes sort keys during shard assignment for partitions that split into multiple shards.

Distribution & Sorting Modes Matrix

Mode Configuration Network Shuffles (Batch) Network Shuffles (Streaming) File Sort & Range Guarantees
NONE (Unsorted) .withDistributionMode(NONE) 0 (Direct bundle write; 1 GroupByKey only if open writers overflow MAX_NUM_WRITERS) 0 (under directWriteByteLimit) or 1 (GroupByKey across shardingKey % 50) Files are written in arrival order.
NONE (Sorted) .withDistributionMode(NONE).withSortedWrites() 0 (In-bundle external sort per (dest, partition)) 0 (under directWriteByteLimit) or 1 (GroupByKey across shardingKey % 50) Every file is internally sorted by table.sortOrder(); min/max key ranges across different worker bundles may overlap.
HASH .withDistributionMode(HASH) (+ optional .withSortedWrites(), .withAutosharding()) 1 (GroupByKey by (dest, partition)) 1 (GroupIntoBatches by (dest, partition)) Consolidates files per partition. With .withSortedWrites() (without autosharding), files within each partition have strictly non-overlapping sort-key ranges.
RANGE (Dynamic Quantiles) .withDistributionMode(RANGE) (no distributionFunction, Batch) 1 (GroupByKey by (dest, partition, quantileShard) + lightweight quantile side-input Combine.perKey) (Requires withDistributionFunction or .withAutosharding()) Dynamically sizes shard count to totalPartitionBytes / targetFileSize using weighted reservoir quantiles; files within each partition have non-overlapping sort-key ranges.
RANGE (Custom Function) .withDistributionMode(RANGE).withDistributionFunction(fn) 1 (GroupByKey by (dest, partition, fn(row))) 1 (GroupIntoBatches by (dest, partition, fn(row))) Every file is internally sorted; cross-file ranges are non-overlapping if fn(row) is monotonic with respect to table.sortOrder().

Testing & Verification

  • Unit Tests (265 tests in :sdks:java:io:iceberg:test):
    • IcebergRowSorterTest: Order-preserving byte encoding across all primitive/logical types, ASC/DESC inversion, NULLS_FIRST/NULLS_LAST matrix, non-identity transforms (month, hour, truncate), and external disk spill verification.
    • IcebergIOWriteTest: End-to-end sorted writes across NONE, HASH, HASH + autosharding, RANGE (custom function and dynamic quantile sharding), post-sort file rolling without range overlap, and streaming directWriteByteLimit bundle sorting.
    • AssignDestinationsAndPartitionsTest: Side-input metadata resolution and Efraimidis–Spirakis weighted reservoir sampling under extreme bundle skew.
    • IcebergIOSideInputTableCacheTest & IcebergWriteSchemaTransformProviderTest: Parameterized across NONE, HASH, HASH + autosharding, and RANGE.
  • Live Dataflow Scale Testing (Batch & Streaming):
    • Verified on Dataflow across 4 pipelines (batch_none_sorted, batch_range_dynamic, stream_none_sorted, stream_range_dynamic) writing to a 22-column partitioned & composite-sorted Iceberg table (primary_type identity partition, sorted by date ASC NULLS FIRST, id DESC NULLS LAST) on GCS.
    • Audited Parquet row-group statistics (min/max) and row-by-row ordering across all committed files (0 sort violations, 0 overlapping key ranges across dynamic RANGE shards, 0 worker errors).

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces native support for sorted writes in IcebergIO. By integrating a memory-safe, disk-spilling sorter, the implementation ensures that data written to sorted Iceberg tables is perfectly ordered, which significantly improves performance and reduces resource contention by minimizing concurrent file writers. The changes include a comprehensive byte-encoding protocol for lexicographical sorting and updated write-path logic to handle sorted tables efficiently.

Highlights

  • Sorted Writing Support: Implemented high-performance sorted writing in IcebergIO by dynamically pre-sorting incoming rows based on the target table's SortOrder.
  • Memory-Safe Sorter: Introduced IcebergRowSorter, which leverages Beam's BufferedExternalSorter to spill data to disk when memory limits are exceeded, preventing OOM errors.
  • Lexicographical Encoding: Added a robust byte-encoding mechanism that supports complex composite keys, null-ordering contracts, and type-specific sorting (including algebraic number handling).
  • Write Path Optimization: Updated WriteUngroupedRowsToFiles and WriteGroupedRowsToFiles to automatically detect table sort requirements and route data through the sorting path.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@atognolag
atognolag marked this pull request as draft May 7, 2026 15:33

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request implements row sorting for Iceberg writes by introducing the IcebergRowSorter utility, which leverages BufferedExternalSorter to handle large datasets. The implementation supports complex Iceberg SortOrder configurations by encoding sort keys into byte arrays. Review feedback identified critical logic errors in the null-ordering byte prefix calculation for descending columns, which were also propagated into incorrect test assertions. Additionally, several performance optimizations were suggested to avoid expensive row conversions and redundant lookups within loops, and to reduce GC pressure from frequent buffer allocations. A potential reliability issue was also noted regarding the consumption of non-re-iterable collections when extracting schemas.

@atognolag

Copy link
Copy Markdown
Contributor Author

@ahmedabu98 mind taking a look? Thanks!

@ahmedabu98 ahmedabu98 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.

Left some comments.

I think more thought needs to be put into the architecture of Sorted writes. This is a primitive approach that might work well for small writes, but will quickly degrade for medium~larger writes.

Maybe take a look at how Flink or Spark do it (look for "range" distribution mode). IIRC they do a global sort before passing data to writers. This way, input iterables are already sorted so writers can break them up into files with tight min/max ranges.

Comment on lines +125 to +130
if (icebergRecord == null) {
icebergRecord = IcebergUtils.beamRowToIcebergRecord(icebergSchema, row);
}
Object icebergVal = icebergRecord.getField(colName);
if (icebergVal != null) {
val = field.transform().apply(icebergVal);

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 is pretty heavy to extract the transformed value. Will make writes pretty slow

Can we apply the transform to the Beam field object directly? If not, maybe we need a "beam object to iceberg object" conversion. IcebergUtils.copyFieldIntoRecord does something similar, but we may need to refactor it a little to fit this use case.

try {
for (Row row : rows) {
byte[] keyBytes = encodeSortKey(row, sortOrder, columnNames, icebergSchema, beamSchema);
ByteArrayOutputStream baos = new ByteArrayOutputStream();

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.

Let's to clear and reuse the output stream object instead of creating a new one each iteration

org.apache.beam.sdk.schemas.Schema beamSchema)
throws IOException {

ByteArrayOutputStream baos = new ByteArrayOutputStream();

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.

Same thing here, let's create ByteArrayOutputStream it in the caller function and pass it in here. Each call should reset and use the same one for efficiency

Comment on lines +241 to +243
} else {
writeString(val.toString(), baos, invert);
}

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.

Does Iceberg support sorting on complex types (lists, maps, structs) ?

We should either 1) add support for that or 2) throw an Unsupported Exception early on.

I'd rather throw an error than fall back on String because it may lead to unexpected sorting behavior for some types.

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.

It is not supported. It now raises an UnsupportedOperationException

Comment on lines +133 to +136
Iterable<Row> sortedOrUnsortedRows =
IcebergRowSorter.sortRows(
element.getValue(), table.sortOrder(), table.schema(), dataSchema);
for (Row row : sortedOrUnsortedRows) {

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.

We should only be sorting if the user asked us to.

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.

IcebergRowSorter.sortRows() will only sort if the table schema defines any sort configuration or else, it does nothing.

@atognolag

Copy link
Copy Markdown
Contributor Author

I have updated this PR to address your (Ahmed) feedback and optimize the sorting implementation. Below is a summary of the changes:

1. Configurable Distribution Modes
Introduced configurable Distribution Modes in IcebergIO.WriteRows to manage sharding and sorting behaviors before writing:

  1. HASH (Default): Shuffles and groups data by partition key ({destination, partition}) to consolidate files per partition and prevent cross-worker file overlaps. Supports optional dynamic auto-sharding (withAutosharding()) to split hot partitions (but losing the non-overlapping files condition).

  2. RANGE: Shuffles data based on a user-supplied SerializableFunction<Row, Integer> (shard function) to distribute hot partitions across parallel sharded workers, keeping file min/max key ranges non-overlapping (provided the user logic does not randomize shard IDs).

  3. NONE: No network shuffle. Best for lightweight pipelines that rely on post-fact compaction or read-time sort merges.

2. Performance Optimizations (GC & CPU Overhead Reductions)
Optimized the sorting pipeline in IcebergRowSorter to reduce CPU cycles and GC memory pressure:

  1. Field-Level Value Conversion: Added beamValueToIcebergValue to IcebergUtils. This converts only the specific fields requiring a sort transform, completely bypassing the expensive row-to-record conversion (beamRowToIcebergRecord) inside the sorting loop.
  2. Stream Reuse: Reuses and resets (.reset()) the ByteArrayOutputStream in the sorting loops to eliminate short-lived output stream allocations.
  3. Primitive Serialization: Encodes numeric fields using bitwise shifts directly into the output stream, eliminating intermediate ByteBuffer and byte[] allocations.

3. Sorter Correctness & Safety

  1. Null-Ordering Correctness: Corrected the null-ordering prefix byte logic for descending sort orders. Prefix bytes now remain consistent regardless of sorting direction (which is already handled by bitwise inverting values), resolving the descending null order bugs.
  2. Sorting Type Safety: Throws UnsupportedOperationException when attempting to sort on complex types (maps, lists, structs) to prevent non Iceberg compliant sorts.
  3. Deprecated API Removal: Removed the unused, deprecated encodeSortKey overload from IcebergRowSorter since it is a package-private utility class and not used elsewhere.

4. Verification & Test Coverage

  1. Checkstyle & Linter Warnings: Documented all exposed configurations and resolved Google Error Prone warnings.
  2. New Test Cases: Added testRangeDistribution to IcebergIOWriteTest to verify range-based writes with custom sharding. Added a test verifying ValidationException is thrown when trying to construct a sort order with complex types.

@atognolag
atognolag force-pushed the feature/sorted-icebergIO branch from fbad2a0 to acf0e56 Compare May 8, 2026 14:14
@derrickaw

Copy link
Copy Markdown
Collaborator

Hi @atognolag, can you fix the conflicts and any other errors? Then @ahmedabu98 can review again. Thanks!

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

This pull request has been marked as stale due to 60 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@beam.apache.org list. Thank you for your contributions.

@github-actions github-actions Bot added the stale label Sep 7, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been closed due to lack of activity. If you think that is incorrect, or the pull request requires review, you can revive the PR at any time.

@github-actions github-actions Bot closed this Sep 14, 2026
@atognolag atognolag changed the title Implement high-performance sorted writing support in IcebergIO [IcebergIO] Support sorted writes and RANGE distribution mode (#38406) Oct 16, 2026
@atognolag

Copy link
Copy Markdown
Contributor Author

Superseded by #40653 (rebased on master with all review comments addressed, zero-shuffle NONE bundle sorting, and dynamic quantile sharding for RANGE). cc @ahmedabu98

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.

3 participants