Delta: Updating Delta to Iceberg conversion#15407
Delta: Updating Delta to Iceberg conversion#15407vladislav-sidorovich wants to merge 43 commits into
Conversation
anoopj
left a comment
There was a problem hiding this comment.
Thank you for the PR. Moving to the Delta kernel is a great improvement. Here is my initial feedback.
| import io.delta.kernel.exceptions.TableNotFoundException; | ||
| import io.delta.kernel.internal.DeltaHistoryManager; | ||
| import io.delta.kernel.internal.DeltaLogActionUtils; | ||
| import io.delta.kernel.internal.SnapshotImpl; |
There was a problem hiding this comment.
We are using internal APIs of the kernel. This is fragile - can we refactor this to use the public APIs instead? Snapshot, Table etc. Or are we doing this because we are trying to preserve the table history during the conversion? I would try to avoid this as much as possible.
There was a problem hiding this comment.
No, there are no public API available for these purposes we need.
Yes, I want go through table history step by step, so we will have exactly the same granularity in the history.
At the same time it's quite safe to use an internal API because it's depends on the Delta protocol which is stable.
There was a problem hiding this comment.
The internal APIs can change or disappear without any notice. I would think hard about avoiding dependencies on internal APIs, including changing semantics. (e.g. not preserving all the history by default).
There was a problem hiding this comment.
I moved all the internal delta API usage to InternalDeltaKernelUtils so it will be easier to refactor it in the future.
Additionally, InternalDeltaKernelUtils now represents all the internal API that missed in public api and can be used as the list for contributions to delta kernel lib.
842ee68 to
aa9f316
Compare
HonahX
left a comment
There was a problem hiding this comment.
Thanks for the great effort in moving to delta kernel given that delta standalone is archived and deprecated! Left some comments, please let me know WDYT!
| assertThat(deleteDeltaLogFile("00000000000000000000.json")).isTrue(); | ||
| assertThat(deleteDeltaLogFile("00000000000000000001.json")).isTrue(); | ||
| assertThat(deleteDeltaLogFile("00000000000000000002.json")).isTrue(); |
There was a problem hiding this comment.
Does the log clean-up always to happen with the VACUUM? Will there be case where the old logs are not cleaned up and our conversion fails because of the missing data file (deleted by VACUUM)?
Seems this indicate some requirement for conversion to work when VACUUM is called on the source table, could you please elaborate on the scope of supporting vacuum in this PR? Thanks!
There was a problem hiding this comment.
The short answers:
- No, logs clean-up is not the same as VACUUM
- The conversation will not failed.
Explanations:
The logs clean-up and VACUUM are 2 parallel and different processes. In this test I intentionally delete some logs (not all) to make the delta versions not re-creatable and not continuous (started from table creation). For example in this test Delta versions 3-9 are exist but are not re-creatable because the data files were deleted by VACUUM.
So the first re-creatable version in this test is 10, this version used as initial version in the conversion and the test is for that scenario.
To sum-up:
VACUUMandlogs clean-upare 2 different processes.- In this test I first execute
VACUUMwithRETAIN 0 HOURS. - After some DML operation I simulated (as written in the comment) logs
clean-up. - Verify that conversion works as expected.
|
|
||
| private void commitDeltaSnapshotToIcebergTransaction( | ||
| SnapshotImpl deltaSnapshot, Transaction transaction, Set<String> processedDataFiles) | ||
| throws IOException { |
There was a problem hiding this comment.
In the old implementation, the commitInitialDeltaSnapshotToIcebergTransaction, will first to find the actual earliest reconstructable version that have all the data files available. That could help reduce the conversion failure due to VACUUM cleaned up files and breaks time travel ability. What's the design choice behind not using that in the new impl?
There was a problem hiding this comment.
The intention is to support VACUUM operation as well and there is a dedicated test for it org.apache.iceberg.delta.TestSnapshotDeltaLakeKernelTable#testConversionAfterVacuum.
The logic is the very similar to the previous one:
- Find first retractable commit => Create Iceberg commit.
- Go 1 by 1 per Delta version after the recreatable commit.
The diff is in commitDeltaSnapshotToIcebergTransaction vs convertEachDeltaVersion. While methods are similar, the way how to collect required data files is different.
| @TempDir private File sourceLocation; | ||
| @TempDir private File destinationLocation; | ||
|
|
||
| public TestSnapshotDeltaLakeKernelTable() { |
There was a problem hiding this comment.
Shall we add tests for unpartitioned table and a table with possible all data types? Also would be good if we have some check similar to checkDataFilePathsIntegrity to verify that data file path is absolute and matches those in delta table.
There was a problem hiding this comment.
There is unit-test to cover all possible data types TestDeltaLakeKernelTypeToType including structs.
Unpartitioned table also covered by unit-test in TestBaseSnapshotDeltaLakeKernelTableAction. Actually, I tried to move more logic to unit tests.
I will also add more tables from https://github.com/delta-io/delta/tree/master/connectors/golden-tables/src , so unit-test will be robust. I removed these tables from the initial PR to reduce number of files in the PR.
Regarding checkDataFilePathsIntegrity I'm not 100% sure. There is no simply utility method that will help to collect all data files, so it means we will need to introduce a logic into the test similar to one we have in the conversion. At the same time this test has checkSnapshotIntegrityForQuery which verify full data for the table per tag/version. Could you clarify what test scenarios is missing and what do you want to test more?
Refactored getFullFilePath to use org.apache.hadoop.fs.Path for more robust absolute and local path handling. Expanded TestDeltaLakePathHandling with additional edge cases including nested paths and special characters. Fixed several PatternMatchingInstanceof warnings and resolved var usage in BaseSnapshotDeltaLakeKernelTableAction.
|
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. |
|
@vladislav-sidorovich is this ready to one more round of review? |
The initial inputs from you were addressed. But let me double check everything this weekends to avoid double work from you and other reviewers. |
|
I went though the comments and the code. Let me sum-up the current state:
@anoopj @HonahX @laskoviymishka I would love to get next cycle of your review and input. Thank you for the contributions to the PR. |
laskoviymishka
left a comment
There was a problem hiding this comment.
Thanks for sticking with the review iterations. I went back through the existing threads before adding anything here, so I’m not reopening what’s already settled.
I like the overall plan: land the Kernel path as an experimental, opt-in utility (snapshotDeltaLakeKernelTable) while keeping the standalone action as the default. I’m also fine with the M1–M7 direction.
For this PR, my merge bar: every path reachable through the new utility either works or fails clearly. Nothing should silently write the wrong table.
Things I’d fix here:
- The DV
OutputFileFactoryis fixed at(1, 1)and reused for a newBaseDVFileWriterperAddFile. With multiple DVs, files can collide and overwrite each other. The factory is also never closed. execute()returns a method-reference lambda instead ofImmutableSnapshotDeltaLakeTable.Result. Besides theintvslongmismatch, it diverges from the Immutables contract used by the standalone implementation.icebergPropertiesBuilderis an instanceImmutableMap.Builder, so duplicate keys throw when user properties overlap Delta config, or whenexecute()runs twice. A local map with later-wins semantics is safer.- The golden-table test only checks that
execute()doesn’t throw. It should assert at least schema, row count, and partitioning, or be disabled with a follow-up issue.
One question: does testConversionAfterVacuum cover a Remove whose data file was physically vacuumed, or only log cleanup? If the file itself is gone, that’s the case I’d want covered because it exercises the exists() / metrics read path.
The typo, Set.of vs relocated ImmutableSet, and unused @TempDir are minor.
For the larger follow-ups, I’d move them out of the dev doc and into GitHub issues:
- migrate off
io.delta.kernel.internal.*; I’d make this the gate for turning the Kernel path into the default - retire the standalone implementation and wire the Kernel path into
DeltaLakeToIcebergMigrationActionsProvider - track the deferred work: VACUUM/Remove via
AddFile.stats, full DV update semantics, and schema evolution
Once the correctness issues are fixed and the follow-ups are tracked, happy to take another pass and approve.
| transaction.newAppend().commit(); | ||
|
|
||
| icebergDVFileFactory = | ||
| OutputFileFactory.builderFor(transaction.table(), 1, 1).format(FileFormat.PUFFIN).build(); |
There was a problem hiding this comment.
The factory is pinned at partitionId=1, taskId=1, and convertDeltaDVsToIcebergDVs spins up a fresh BaseDVFileWriter per AddFile against this same factory.
Since the factory's counter is the only thing varying the filename and each writer starts from the same fixed seed, the Puffin DV files can land on colliding paths and overwrite each other across files — so we can't really claim correct DV conversion without fixing this alongside the DV double-delete above. Sharing one factory so the counter increments across all DV writers is what the code looks like it intended. OutputFileFactory is also Closeable and this one is never closed, worth handling in the same pass.
There was a problem hiding this comment.
I'm not sure I get an issue with the files colliding paths. This method used for each DV puffin file generation: https://github.com/apache/iceberg/blob/main/core/src/main/java/org/apache/iceberg/io/OutputFileFactory.java#L106
So, fileCount.incrementAndGet() will always generate next sequential (and unique) file name. It seems that partitionId=1, taskId=1 are need for execution in cluster (on different workers), with the current conversion scenario the usage seems valid. Do I miss anything?
| originalCommitTimestamp = | ||
| commitInfo.getLong(commitInfo.getSchema().indexOf("timestamp")); | ||
|
|
||
| assertSupportedDeltaOperation(deltaVersion, commitInfo); |
There was a problem hiding this comment.
The schema-evolution guard only runs inside this isCommitInfo(row) branch — so a version that carries no commitInfo row skips the check entirely. Delta doesn't guarantee a commitInfo per version, and the schema is fixed once from initialDeltaSnapshot (line 194), so an ADD/RENAME/DROP COLUMN landing in a commitInfo-less version would convert silently against the wrong schema and write a truncated Iceberg table.
That's the silent-corruption case I'd want closed before merge. I'd lift the schema-consistency check out of the per-row commitInfo parsing and verify the schema is stable across the whole [minimalAvailableDeltaVersion, latestDeltaVersion] range up front — that way the guard doesn't depend on an action Delta may not have written. wdyt?
There was a problem hiding this comment.
Good point, let me rewrite it to rely on metaData
| "SET TBLPROPERTIES"); // The operations will be supported eventually | ||
|
|
||
| private final ImmutableMap.Builder<String, String> icebergPropertiesBuilder = | ||
| ImmutableMap.builder(); |
There was a problem hiding this comment.
icebergPropertiesBuilder is an instance field that accumulates across calls, and ImmutableMap.Builder.build() throws on duplicate keys.
buildTablePropertiesWithDelta puts the internal props and then putAll(configuration), on top of whatever the user passed via tableProperties/tableProperty. Any overlap — a user prop that also appears in the Delta config, or a second execute() on the same action — throws IllegalArgumentException. I'd make this a local HashMap inside the build method, populate user props → Delta config → required Iceberg props so later wins, and return ImmutableMap.copyOf(...).
| .icebergCatalog(testCatalog) | ||
| .tableLocation(newTableLocation); | ||
|
|
||
| testAction.execute(); |
There was a problem hiding this comment.
goldenDeltaTableConversion only asserts that execute() doesn't throw — every golden table passes as long as conversion completes, regardless of whether the result is correct.
For an experimental path that's especially risky, because green CI then implies coverage that isn't actually there. I'd either load the resulting Iceberg table and assert schema, row count, and partitioning against the source, or mark it @Disabled("follow-up #<issue>") so it's honest about what it does and doesn't verify. This is the natural place to catch a silently-wrong DV or Remove conversion.
@laskoviymishka Thank you for the review and the summary comment.
The following sequence of action is possible in multiple Delta commits and handled in The factory is not closable, but
Done.
throw on duplicate keys is an expected behavior. It’s never happened in a proper execution but will throw an exception for invalid usage. This strategy also aligns with your previous comments regarding fail with explicit message vs silent corruption.
It's actually an interesting topic to discus. My ideas was the following: use unit-tests
What do you think? Other topics:
|
laskoviymishka
left a comment
There was a problem hiding this comment.
Thanks @vladislav-sidorovich for write up!
Dived into the DV-update path I flagged as a blocker last round: Add+Remove of the same path, the core DELETE/MERGE/UPDATE path on DV-enabled tables.
That path now produces a valid snapshot. I traced it: each Delta DV-delete is remove(file) + add(file) with a cumulative DV, and the converter's removeRows(file) drops the prior DV while the new cumulative DV replaces it — so the earlier "addRows + removeRows on the same file" concern is reconciled in practice, even if implicitly rather than via an explicit removeDeletes.
To convince myself, I built a repro: single data file (range(0, 10000, 1, 1)), DELETE id = 1 then DELETE id = 2 as two commits. This is the exact case my last note said the tests couldn't catch — row content can't see it, because the v1 DV {1} is a subset of the v2 DV {1,2}, so the union Iceberg applies is correct (9998 rows) whether or not the stale DV is cleaned up. The assertion that actually pins it down is the delete-file count:
assertThat(spark.sql("SELECT * FROM " + table + ".delete_files").count()).isEqualTo(1L);
Green: exactly one DV on the file in the latest snapshot, no dangling superseded DV, V3 one-DV-per-file invariant holds. The other blockers from last round look addressed too (per-Delta-version commit atomicity, schema-evolution handling).
Stepping back, though, this is incremental, not a finish line, and the plan for the rest already exists: the Future steps in the description (all Delta actions; all Delta features like column mapping / generated columns; partition + generated-column edge cases; schema evolution; incremental conversion) plus the golden-tables suite and the dev doc. My ask is to make that plan visible and trackable in the open, a tracking issue with sub-issues per step, so the staged work is reviewable as it lands instead of living only in the PR body.
Net: the blocking correctness concern on the central DV path is resolved and verified. Treating this as a staged step, good to keep moving, with the remaining scope tracked openly via the tracking issue + sub-issues rather than silently closed.
Before merging will wait for (@anoopj | @huan233usc) && (@kevinjqliu | @HonahX)
Current PRs contains initial version of the code to update of the existing functionality: https://iceberg.apache.org/docs/1.4.3/delta-lake-migration/ to the recent Delta Lake version (read: 3, write: 7). The motivation of the PR is to receive the earliest feedback from the community.
Note: The PR doesn't remove the old logic but adds new Interface implementation, so it will be easier to compare/review. Also base on the usage scenario of the module, such approach will not introduce any issues.
More detailed development doc.
The PR scope:
Addaction)Removeaction)Future steps:
Tests:
Unit-tests: contains all supported datatypes including complex arrays and structures.
Integration-tests: contains inserts only scenario with Spark 3.5. The test must be updated for newer Delta Lake version once the previous solution will be deleted from the code.
In the following PRs, I will add all the tables from: Delta golden tables