Spark 4.1: Add scoped replacement writes (INSERT INTO ... REPLACE USING)#16795
Open
liucao-dd wants to merge 5 commits into
Open
Spark 4.1: Add scoped replacement writes (INSERT INTO ... REPLACE USING)#16795liucao-dd wants to merge 5 commits into
liucao-dd wants to merge 5 commits into
Conversation
Add the parser front-end and logical node for scoped-replace, a data-dependent overwrite with Delta replaceUsing semantics: delete every target row whose scope columns match the source, then append all source rows in a single snapshot. The Iceberg extension grammar is DDL-shaped and cannot tokenize an arbitrary trailing query, so the statement is split at the text level. The head, INSERT INTO t REPLACE USING (cols), is parsed by a new grammar rule, and the trailing query is delegated to Spark's parser. String literals and comments are masked before locating the scope list so the split and routing heuristic cannot be fooled by their contents. Scoped-replace command detection is anchored to the INSERT head, so ordinary query bodies remain delegated to Spark even when they contain replaceUsing-like text, comments, or a join alias named replace followed by USING. This commit only produces the ReplaceScopedData logical node; the rewrite that lowers it into the row-level write path is added separately so copy-on-write, merge-on-read, and deletion-vector selection can be reviewed with the write logic.
Add RewriteScopedReplace, a post-hoc resolution rule that lowers the ReplaceScopedData node into Spark's row-level write path, and register it in IcebergSparkSessionExtensions. The command is requested as MERGE so write mode is selected through the same table config path as MERGE. For copy-on-write tables it emits a ReplaceData whose replacement query is a union of two branches over a CTE-shared source. The carryover branch scans target rows that match no source scope with a left anti join and tags them WRITE_WITH_METADATA so grouping metadata is preserved. The insert branch aligns and casts every source row to the target schema, then tags it WRITE. Sharing the source through a CTE makes a deterministic source inline and a non-deterministic source evaluate once, so the anti join, inserts, and runtime group filter observe the same rows. For merge-on-read tables it emits a WriteDelta. The delta plan shares the same source CTE, emits one DELETE row for each target row whose scope matches the source with a left semi join, and emits one INSERT row for every source row. This preserves scoped-replace semantics when duplicate source rows share a scope: target rows are deleted once, while all source rows are appended. Iceberg still chooses deletion vectors or position deletes according to table format and write configuration. Scoped-replace sources are aligned through Spark's table output resolver so store-assignment rules match INSERT semantics. Nondeterministic scoped-replace sources are not used as row-level operation conditions because Spark evaluates those conditions outside the write query and requires them to be deterministic. In that case the rewrite falls back to an unconditional operation, preserving correctness while making the full-table rewrite tradeoff visible in logs. The row, metadata, and delta projections are built directly because the union top nodes are not introspectable by Spark's framework helpers.
Add Spark extension coverage for INSERT INTO ... REPLACE USING under both copy-on-write and merge-on-read row-level write modes. The shared suite verifies the core scoped-replace contract: target rows are deleted by null-safe scope equality, non-matching rows are retained, all source rows are appended, and duplicate source scopes delete target rows only once. It also covers new source scopes, mixed matching and new scopes, empty sources, multi-column scopes, null scope values, nondeterministic sources, and Spark store-assignment behavior. The parser coverage verifies that regular INSERT queries with replaceUsing-like text in string literals, comments, nested block comments, and query bodies remain ordinary INSERT statements. It also verifies that comments in the command head do not prevent scoped-replace routing. The COW and MOR subclasses pin the write-mode selection through table properties and assert the resulting snapshots use rewritten data files or row deltas respectively, including deletion-vector summaries on v3 tables.
Document the supported REPLACE USING form for Spark writes. The docs describe the scoped replacement semantics, show the column-list SQL shape, and call out that REPLACE ON and native Spark grammar support are future work.
84b702c to
8f5d729
Compare
Allow DataFrameWriterV2 jobs to opt into scoped replacement with a write option while preserving normal Iceberg write options and branch selection.
e45fce0 to
c5f8195
Compare
Contributor
|
This is something that must be done in Spark. Happy to help, but extensions are not the right fit. |
Contributor
|
Spark added support for the syntax recently, btw. |
Author
|
realized apache/spark#54722 is done and this was internally built before that. Will look into porting over to spark instead, thx for the quick note @aokolnychyi |
Author
|
opened spark PR instead to implement this properly apache/spark#56705 iceberg adoption will only be possible after spark releases |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part 1 work of #16785.
What
This adds scoped replacement writes to the Iceberg Spark 4.1 extensions. A scoped replacement treats the source as the complete replacement for the scopes it touches: it reads the distinct scope tuples from the source, deletes target rows whose scope shows up in the source, appends every source row once, and commits the delete and append as a single Iceberg snapshot. Scopes that the source never mentions are left alone. This is the "full group refresh" operation from #16785: atomic, with no two-commit DELETE + INSERT and no stretched
MERGE INTO ... WHEN NOT MATCHED BY SOURCE.There are two ways to invoke it.
DataFrame API (merge-eligible API for spark 4.1)
Dataframe API -
DataFrameWriterV2with thereplace-usingwrite option and an unconditional overwrite:overwrite(lit(true))is required: the option only converts a full overwrite into a scoped replacement, so any other overwrite filter (orappend/overwritePartitions) is rejected with a clear error rather than silently ignoring the option. Normal Iceberg write options are preserved and flow through unchanged — for example target file size, compression, distribution mode, andbranchselection:This surface works uniformly from Scala, Java, and PySpark because all three route through the same
DataFrameWriterV2→OverwriteByExpressionlogical plan. It is gated strictly to Iceberg tables; areplace-usingoption on a non-Iceberg DSv2 table is left untouched.SQL (demonstration only, need Spark grammar addition ideally)
Scope of this PR
This PR covers part of #16785. A few things are left for later on purpose:
REPLACE USING (cols)/replace-using) is implemented. The arbitrary-condition form (REPLACE ON <expr>) from the issue is not here yet.insertIntogrammar does not acceptREPLACE USING, and the Iceberg extension grammar cannot own an arbitrary trailing Spark query. So the SQL path uses an in-place text split as a stopgap: the Iceberg ANTLR grammar parses only the command head (singleScopedReplaceHead:INSERT INTO t REPLACE USING (cols)), and the remaining query tail goes to the wrapped Spark parser. Detection is anchored to the statement head with literals and comments masked, so aREPLACE USING (later in the query body (say, a join alias) does not trigger the path.Note
The DataFrame API is self-contained and does not depend on the SQL grammar. Both DF and SQL reuses the same underlying
ReplaceScopedDataandRewriteScopedReplace. Because the.g4text-split SQL parsing is a stopgap (the real end state is to proposeREPLACE USING/REPLACE ONfor Spark's nativeinsertIntogrammar), the SQL grammar / parser / AST-builder pieces and the SQL-only tests may be removed before this PR is merged to keep it focused on the DataFrame API.How it works
ReplaceScopedData(table, scopeColumns, source).RewriteDataFrameScopedReplacedetects areplace-usingOverwriteByExpressionon an Iceberg table, validates the unconditional overwrite, merges the command and relation write options, eagerly resolves the write branch (Spark's post-hoc resolution batch runs only once, so the branch is pinned here rather than relying on a later pass), parses the scope columns, and emitsReplaceScopedData.RewriteScopedReplacelowersReplaceScopedDatathrough Iceberg's existing row-level write path. It requests the operation asMERGEso the table configuration decides between copy-on-write and merge-on-read (and deletion vectors):ReplaceDataWriteDeltaTests
TestReplaceScopedDataDataFramecovers the DataFrame surface: scope deletion, full source append, untouched scopes, empty source, duplicate scope keys, null scope values, multi-column scopes, write-option and branch passthrough, and validation (non-trueoverwrite, append/overwritePartitions, non-Iceberg tables, empty scope tokens).TestCopyOnWriteReplaceScopedDataDataFrameandTestMergeOnReadReplaceScopedDataDataFrameexercise both write modes.TestReplaceScopedDatacovers the shared semantics via SQL;TestCopyOnWriteReplaceScopedDataandTestMergeOnReadReplaceScopedDataexercise both write modes. (These SQL-only suites go away if the SQL grammar path is dropped per the note above.)Docs
docs/docs/spark-writes.mddocuments the scoped replacement DataFrame API and the supportedREPLACE USINGSQL form, and notes thatREPLACE ONand native Spark grammar support are future work.Follow-ups
REPLACE USING/REPLACE ONseparately.REPLACE ON <boolean_expr>form.IN (...)predicate to feed Iceberg's static filter.