Skip to content

[lake/iceberg] Support clean and legacy Iceberg lake table schemas - #4019

Open
fhan688 wants to merge 3 commits into
apache:mainfrom
fhan688:Support-clean-and-legacy-Iceberg-lake-table-schemas
Open

[lake/iceberg] Support clean and legacy Iceberg lake table schemas#4019
fhan688 wants to merge 3 commits into
apache:mainfrom
fhan688:Support-clean-and-legacy-Iceberg-lake-table-schemas

Conversation

@fhan688

@fhan688 fhan688 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Purpose

Linked issue: close #3903

Sub-task of the FIP-27 umbrella (#2411): Remove Mandatory System Columns From Fluss Lake Tables.

Today every Iceberg lake table Fluss creates is forced to carry three mandatory system columns (__bucket, __offset, __timestamp) as its last physical columns. They pollute the schema users see from Iceberg and other engines. Unlike Paimon, __bucket is additionally woven into the physical layout as an identity(__bucket) partition field for bucket-unaware tables, and __offset drives the table sort order.

This PR implements the Iceberg part of FIP-27, mirroring the Paimon part (#3902): newly created Iceberg lake tables use a clean physical schema containing only user-defined columns, while existing legacy tables that still carry the three system columns remain fully readable and writable without any schema migration. Both layouts are supported across create, tiering writers, readers, projections, schema evolution, compaction, and re-enabling tiering.

Brief change log

  • Layout detection (single source of truth): add IcebergUtils.isLegacyTable(Schema), defined as schema.findField("__timestamp") != null. Detection is purely schema-based — no new table property or metadata, so existing tables are never migrated.

  • Create: IcebergSchemaUtils.createIcebergSchema no longer appends the system columns; new tables are clean. SYSTEM_COLUMNS is renamed to LEGACY_SYSTEM_COLUMNS, and a new createLegacyIcebergSchema reproduces the legacy layout for compatibility checks. The user-column name-conflict check against system names is kept.

  • Partition spec (Iceberg-specific): in IcebergPartitionSpecUtils, a bucket-unaware clean table leaves the spec unpartitioned instead of adding identity(__bucket) (clean tables have no __bucket column). Legacy tables keep the identity(__bucket) partition; bucket-aware tables keep their bucket(userCol) transform.

  • Sort order (Iceberg-specific): IcebergLakeCatalog.createSortOrder returns SortOrder.unsorted() for clean tables, which have no __offset to sort by; legacy tables keep asc(__offset).

  • Schema evolution: applySchemaChanges inserts a new business column before the first system column only for a legacy table; for a clean table it is appended normally. AddColumn rejects names that collide with LEGACY_SYSTEM_COLUMNS via InvalidTableException.

  • Compatibility / re-enable tiering: isIcebergSchemaCompatible detects the existing layout and, for a legacy table, compares against the legacy schema (createLegacyIcebergSchema) so disabling and re-enabling tiering preserves the physical layout. IcebergPartitionSpecValidator forks the same way when validating the target spec.

  • Row tiering writer: FlussRecordAsIcebergRecord emits the three system values only for legacy tables; for clean tables the business-field count equals the full row and no system fields are written. The layout flag is threaded through RecordWriter.

  • Split planning / bucket extraction (Iceberg-specific): IcebergSplitPlanner.createBucketEy-spec case (clean bucket-unaware table → bucket = -1) and detects the bucket slot bytransform type (bucket[…]) rather than by source column name, so clean bucket-aware tables — which carry a bucket(userCol)transform but no__bucket` column — work correctly.

  • Partition / filter conversion (Iceberg-specific): IcebergConversions.toPartition sets the bucket slot only when the last partition field is a legacy identity(__bucket) or a bucket[…] transform;
    toFilterExpression adds the equal(__bucket, bucket) predicate only for legacy tables.

  • Compaction: IcebergRewriteDataFiles null-guards the __offset lookup before building t, so clean tables compact without system columns.

  • Reader / projection: IcebergRecordReader projects and reads __offset/__timestamp only for legacy tables. For clean tables it emits a sentinel -1L log offset / timestamp, consistent with the existing
    UNKNOWN_OFFSET = -1 convention.

  • Row adapter: IcebergRecordAsFlussRow computes the business-field count dynamically fromad of hard-subtracting the three trailing system columns, which also fixes latent miscounts on
    projected rows.

Tests

  • Adapted IcebergLakeCatalogTest (create/alter assertions now expect user-only schemas, unparIcebergSchemaEvolutionITCase (column-order assertions changed from
    isLessThan(indexOf("__bucket")) to isEqualTo(fieldNames.size() - 1)), and the FlinkIcebergoffset assertions guarded on __offset` presence) to the clean layout.

  • Legacy coverage retained: IcebergTieringTest, IcebergPartitionSpecValidatorTest, and IcebergSplitPlannerTest continue to exercise the legacy identity(__bucket) / asc(__offset) paths; IcebergSplitPlannerTest additionally covers the empty-spec (clean bucket-unaware) and clean bucket(col) transform cases.

  • Verified locally on JDK 11: unit tests IcebergLakeCatalogTest (40), IcebergTieringTest (6lidatorTest(9),IcebergSplitPlannerTest(6) pass; ITsIcebergTieringITCase (1),IcebergSchemaEvolutionITCase(5),IcebergRewriteITCase(3), andFlinkUnionRead*IcebergITCase` union-read cases all pass in isolation.

API and Format

No public API change. This changes the physical schema of newly created Iceberg lake tables (clean layout by default) and, for bucket-unaware tables, removes the identity(__bucket) partition and asc(__offset) sort
order from newly created tables. Existing tables are not migrated and keep their current physic rolling-upgrade requirements are covered by the umbrella #2411 and documented in #3905:

  • New lake storage plugins and Flink connectors continue reading legacy tables.
  • New tiering services keep writing the legacy layout when the target table has the system colu
  • Old tiering services must not process newly created clean tables; old Flink connectors using ot read newly created clean tables.
  • Safe upgrade order: lake-reading Flink connectors and lake storage plugins → tiering service

Documentation

Feature behavior (clean vs. legacy layouts, detection, and the rolling-upgrade/compatibility mately under #3905. No standalone doc change in this PR.

Copilot AI 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.

Pull request overview

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.

Implements FIP-27 for Iceberg lake tables by introducing “clean” schemas (user columns only) while keeping legacy tables (with __bucket/__offset/__timestamp) fully readable/writable via schema-based layout detection.

Changes:

  • Add schema-based legacy detection (IcebergUtils.isLegacyTable) and thread legacy/clean behavior through writers/readers/planners.
  • Stop appending Iceberg system columns for newly created tables; preserve legacy layout for compatibility checks and certain evolutions.
  • Update partition/sort handling and adjust tests to validate clean-table behavior (unpartitioned/unsorted where applicable).

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/utils/IcebergUtils.java Adds legacy-vs-clean layout detection helper.
fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/IcebergSchemaUtils.java Introduces clean vs legacy schema builders and renames system column map.
fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalog.java Makes schema evolution and sort order conditional on legacy layout.
fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/utils/IcebergPartitionSpecUtils.java Skips legacy identity(__bucket) partitioning for clean bucket-unaware tables.
fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/utils/IcebergConversions.java Makes bucket partition key + bucket filter conditional on spec/schema shape.
fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/tiering/RecordWriter.java Threads legacy-layout flag into tiering record conversion.
fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/tiering/FlussRecordAsIcebergRecord.java Emits system columns only for legacy tables; adjusts positional mapping.
fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/source/IcebergSplitPlanner.java Updates bucket/partition extraction for clean vs legacy partition specs.
fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/source/IcebergRecordReader.java Projects/reads offset+timestamp only for legacy tables; emits sentinel otherwise.
fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/source/IcebergRecordAsFlussRow.java Computes business-field count dynamically to handle clean vs legacy/projections.
fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/maintenance/IcebergRewriteDataFiles.java Skips __offset-based sorting when offset column is absent.
fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalogTest.java Updates expectations for clean schemas, partition specs, and unsorted tables.
fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/flink/FlinkCatalogLakeTest.java Updates schema size expectation to user columns only.
fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/testutils/FlinkIcebergTieringTestBase.java Makes offset assertions conditional; handles clean tables without __offset.
fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/tiering/IcebergSchemaEvolutionITCase.java Updates schema evolution assertions for clean “append-last” behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.


public void setFlussRecord(LogRecord logRecord) {
this.logRecord = logRecord;
this.internalRow = logRecord.getRow();
Comment on lines +72 to +77
boolean lastIsLegacyBucket =
lastSourceField != null && lastSourceField.name().equals(BUCKET_COLUMN_NAME);
boolean lastIsBucketTransform = lastField.transform().toString().startsWith("bucket[");
if (lastIsLegacyBucket || lastIsBucketTransform) {
partitionKey.set(pos, bucket);
}
Comment on lines +161 to +164
Types.NestedField lastSourceField = table.schema().findField(lastField.sourceId());
boolean lastIsLegacyBucket =
lastSourceField != null && lastSourceField.name().equals(BUCKET_COLUMN_NAME);
boolean lastIsBucketTransform = lastField.transform().toString().startsWith("bucket[");
Comment on lines +35 to +44
/**
* Returns whether the given Iceberg table is a legacy table (has the three trailing system
* columns).
*
* <p>Detection: if the {@code __timestamp} system column exists in the physical schema, this is
* a legacy table. Clean tables have no system columns.
*/
public static boolean isLegacyTable(Schema icebergSchema) {
return icebergSchema.findField(TIMESTAMP_COLUMN_NAME) != null;
}
Comment on lines +341 to +344
// FIP-27: a clean table stores only user columns; only legacy tables carry the
// trailing __bucket/__offset/__timestamp columns (offset at idx 3).
if (actualRecord.struct().field(OFFSET_COLUMN_NAME) != null) {
assertThat(actualRecord.get(3)).isEqualTo(startingOffset++);
Comment on lines +423 to +433
Iterable<Record> iterable =
Parquet.read(table.io().newInputFile(file.location()))
.project(table.schema())
.createReaderFunc(
fileSchema ->
GenericParquetReaders
.buildReader(
table.schema(),
fileSchema))
.build();
iterable.forEach(records::add);
@fhan688 fhan688 closed this Aug 17, 2026
@fhan688 fhan688 reopened this Aug 17, 2026
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.

[FIP-27] Support clean and legacy Iceberg lake table schemas

2 participants