Skip to content

beam: add zero-copy Delta Lake to Iceberg metadata translation - #41234

Open
user-Param wants to merge 1 commit into
apache:masterfrom
user-Param:master
Open

user-Param wants to merge 1 commit into
apache:masterfrom
user-Param:master

Conversation

@user-Param

Copy link
Copy Markdown

Fixes #41227

Add apache_beam.io.lakehouse package with translate_delta_to_iceberg()
function that synthesizes Iceberg v2 metadata from Delta Lake transaction
logs without copying underlying Parquet data files.

Signed-off-by: user-Param user-Param@users.noreply.github.com

Add apache_beam.io.lakehouse package with translate_delta_to_iceberg()
function that synthesizes Iceberg v2 metadata from Delta Lake transaction
logs without copying underlying Parquet data files.

Features:
- Parses Delta _delta_log/*.json partition definitions into Iceberg PartitionSpec
- Generates Iceberg ManifestFile records pointing to existing Parquet files
- Writes Iceberg snapshot and table metadata JSON
- Zero-copy: data files are referenced in-place, not duplicated

Fixes apache#41227

Signed-off-by: melon tusk <paramjeetsingh.dev.mail@gmail.com>
@github-actions github-actions Bot added python io size/m M: 50-249 lines changed labels Nov 12, 2026

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

📋 Review Summary

This PR introduces apache_beam.io.lakehouse with a translate_delta_to_iceberg utility intended to synthesize Iceberg v2 metadata from Delta Lake logs. However, the current implementation has critical correctness issues in both Delta Lake log parsing and Iceberg v2 metadata generation that prevent it from working on real Delta tables or producing valid Iceberg tables.

🔍 General Feedback

  • Delta Log Parsing & Active File Resolution: Delta transaction logs (_delta_log/*.json) use newline-delimited JSON (NDJSON) with nested metaData, add, and remove actions. Parsing the entire file via json.loads() fails on multi-line logs, and scanning the filesystem directory directly instead of reconciling add/remove actions risks including deleted/tombstoned data files and _delta_log/*.checkpoint.parquet files.
  • Iceberg v2 Spec Compliance: Iceberg manifest files and manifest lists must be written in binary Apache Avro format (or generated via pyiceberg), not JSON strings saved with an .avro extension. Additionally, the table metadata JSON is missing full data column schemas and required Iceberg v2 fields.
  • Missing Unit Tests: Please add unit tests (e.g., sdks/python/apache_beam/io/lakehouse/translate_test.py) covering partitioned and unpartitioned Delta tables, active vs. removed files, and error handling when _delta_log is missing.

💡 Tip: Comment /gemini review on this pull request to trigger a new Gemini code review.

import os
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

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.

🟢 Low: Optional is imported but unused, which will cause Beam's lint checks to fail.

Suggested change
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List

if not meta.path.endswith(".json"):
continue
content = FileSystems.open(meta.path).read().decode("utf-8")
entry = json.loads(content)

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.

🔴 Critical: Delta Lake commit logs (_delta_log/*.json) are newline-delimited JSON (NDJSON) where each line is a separate action object, and partition columns are nested under entry["metaData"]["partitionColumns"] rather than at the top level. Calling json.loads(content) on the entire file raises JSONDecodeError on any commit file with multiple lines, and the broad except Exception: pass on lines 51–52 silently swallows the error and returns [].

Additionally, the docstring for translate_delta_to_iceberg states that ValueError is raised if no transaction logs are found, so missing/empty logs and malformed entries should not be silently ignored.

"""List all Parquet files under the Delta data directory."""
parquet_files: List[str] = []
try:
result = FileSystems.match([delta_data_dir])

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.

🔴 Critical: Listing Parquet files via FileSystems.match([delta_data_dir]) has two critical issues:

  1. Passing a directory URI without a recursive glob pattern does not match .parquet files inside partitioned subdirectories.
  2. Scanning the directory directly instead of replaying add and remove actions from _delta_log will include tombstoned/compacted Parquet files (prior to VACUUM) as well as _delta_log/*.checkpoint.parquet files, causing data duplication or corruption. Active Parquet files (along with their exact size, partitionValues, and stats such as numRecords) should be extracted by reconciling add and remove actions from the Delta log.

if not table_identifier:
raise ValueError("table_identifier must be specified")

delta_log_dir = os.path.join(delta_table_uri, "_delta_log")

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.

🟡 Medium: Use FileSystems.join instead of os.path.join when constructing filesystem paths and cloud URIs (here and on lines 43, 144, 184, 195, and 230). os.path.join uses OS-specific path separators (\ on Windows), which breaks cloud URIs like gs:// or s3://.

Suggested change
delta_log_dir = os.path.join(delta_table_uri, "_delta_log")
delta_log_dir = FileSystems.join(delta_table_uri, "_delta_log")

)

# Write manifest file
manifest_file = os.path.join(metadata_dir, f"{uuid.uuid4()}.avro")

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.

🔴 Critical: Lines 184–207 write JSON strings (json.dumps(...)) into files named {uuid}.avro and {snapshot_id}.avro. Per the Apache Iceberg specification, both manifest files and the snapshot manifest-list file must be valid binary Apache Avro files written with the Iceberg Avro schemas (and manifest-list must contain manifest_file entries pointing to the manifest Avro files, rather than embedding the snapshot metadata object itself). Any Iceberg reader (PyIceberg, Spark, Trino, BigLake) will reject these .avro files.

"added-data-files": manifest_entries,
"deleted-data-files": [],
}
FileSystems.create(manifest_file).write(json.dumps(manifest_content).encode("utf-8"))

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.

🟠 High: FileSystems.create(...) (here and on lines 207 and 231) and FileSystems.open(...) (line 47) return file handles that are never closed. For remote filesystems in Beam (such as GcsIO and S3IO), FileSystems.create returns a buffered upload channel that must be closed via a with context manager (with FileSystems.create(...) as f:) to flush buffers and finalize the object upload.

"location": delta_table_uri,
"last-updated-ms": int(datetime.now(timezone.utc).timestamp() * 1000),
"last-column-id": 1000 + len(partition_cols),
"schema": {

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.

🔴 Critical: The generated table_metadata is incomplete and does not conform to the Iceberg v2 table metadata specification:

  1. The synthesized schema only includes partition_cols (all hardcoded to "string"), omitting all non-partition data columns defined in Delta's metaData.schemaString.
  2. Iceberg v2 requires "schemas" (a list of schema objects with "type": "struct"), "current-schema-id", "last-partition-id", "sort-orders", and "default-sort-order-id" instead of "schema" and "sort-ids".

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

Labels

io python size/m M: 50-249 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request]: [Lakehouse/Python] Partition-aware zero-copy metadata translation from Delta Lake to Apache Iceberg

2 participants