Skip to content

Support NOT MATCHED BY SOURCE in the GPU MERGE command on Databricks 17.3 [databricks] - #15884

Open
jtwynne wants to merge 18 commits into
NVIDIA:mainfrom
jtwynne:db173-merge-not-matched-by-source
Open

Support NOT MATCHED BY SOURCE in the GPU MERGE command on Databricks 17.3 [databricks]#15884
jtwynne wants to merge 18 commits into
NVIDIA:mainfrom
jtwynne:db173-merge-not-matched-by-source

Conversation

@jtwynne

@jtwynne jtwynne commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Contributes to #8415. This covers the Databricks 17.3 shim (the 14.3 shim has the same command shape and can follow in a separate PR if needed).

Description

On Databricks 17.3 a MERGE with a WHEN NOT MATCHED BY SOURCE clause falls back to the CPU with "notMatchedBySourceClauses not supported on GPU". The fallback costs more than the clause: the 17.3 CPU merge is the row-index-set algorithm, whose scan (file-in-scan id and row-index metadata columns) and rewrite phases the plugin cannot accelerate, so the whole merge runs on the CPU with only the shuffles on the GPU. I hit this on an SCD type 2 pipeline where every dimension merge carries the clause for delete detection.

After this change a MERGE with the clause runs as GpuMergeIntoCommand on Databricks 17.3 when spark.rapids.sql.command.MergeIntoCommand / MergeIntoCommandEdge are enabled, the same way merges without the clause already do. No new configuration. The deletion vector veto is unchanged, so tables with persistent deletion vectors still fall back.

How it is done:

  • The shared merge-join processor (GpuRapidsProcessDeltaMergeJoinExec) now evaluates the not-matched-by-source conditions on target rows that have no source match. processProjectionSeries already implements "first matching clause wins, otherwise the default", so the target-only rows go through it with the no-op copy as the default instead of being copied unconditionally. The two require(...isEmpty) guards become a length check, and the all-empty batch case is handled since the series can now produce nothing.
  • The Databricks 17.3 GpuMergeIntoCommand follows the shape the delta-23x and delta-24x commands in this repo already have (they carry the full implementation but stay vetoed because of the shared processor): findTouchedFiles uses a right outer join and no target-only data skipping when the clauses are present; writeAllChanges builds conditions and outputs for the new clause type, including the CDC pre and post images, through updateOutput, deleteOutput, insertOutput and a clauseOutput dispatch; the insert-only shortcut is disabled when the clauses are present and the matched-only right outer join rule is keyed on "no insert clauses" as in OSS Delta; the row-based fallback processor handles the clauses the same way; the commit records the not-matched-by-source predicates; and the per-clause-type metrics the CPU command reports (numTargetRowsMatchedUpdated, numTargetRowsNotMatchedBySourceUpdated, numTargetRowsMatchedDeleted, numTargetRowsNotMatchedBySourceDeleted) are added, incremented through the same And(metric, metric) pattern delta-24x uses.
  • The veto in the 17.3 MergeIntoCommandMetaShim is removed.
  • Second commit: while running the whole merge test file I found that test_delta_merge_check_overflow_in_table_write_error reports six leaked device column vectors. processProjectionSeries guarded the batch that splitBatchAndClose had already consumed and left the not-matched remainder open when a clause projection throws. It is pre-existing and independent of the first commit, but it is in the function the port relies on, so it is fixed here: the guard now covers the remainder. With the fix the test runs with no leak reports.
  • Third commit (review): the duplicate-match compensation for an unconditional MATCHED DELETE now also adjusts numTargetRowsMatchedDeleted, as OSS Delta and delta-24x do for both counters.
  • Fourth commit (review): Databricks Runtime 16.0+ reports several source rows matching one target row as an error only when more than one of them satisfies the ON condition and a WHEN MATCHED clause condition; the 17.3 command still applied the OSS ON-only rule, which this PR made reachable for NOT MATCHED BY SOURCE merges. findTouchedFiles now counts the pairs that take a WHEN MATCHED action separately and raises the error on that count only (the unconditional-delete exception is unchanged). Because the GPU command is join-based, writeAllChanges additionally keeps one joined pair per target row when findTouchedFiles found rows with several pairs of which at most one takes an action (a row_number window over target and source row ids, preferring the pair that takes the action), so source rows that matched on ON alone are neither inserted nor applied and a row with no applying pair is copied once. The window only runs for merges that have such rows. The intended behaviour was checked against DBR 17.3 CPU with and without low-shuffle merge before writing the tests.
  • Fifth commit (review): a clause condition that evaluates to NULL is false, but the shared processor's splitBatchAndClose filtered each batch with the condition and with its negation, and a NULL passes neither cuDF filter, so such rows were silently dropped: a WHEN MATCHED AND s.flag with a NULL flag deleted the target row, and a NULL NOT MATCHED BY SOURCE condition dropped the target-only row. NULLs in the evaluated condition are now replaced with false before the split (masks without NULLs are used as they are), so the row falls through to the next clause or the default like the CPU row processor. This is in the shared processor and applies to every shim that uses it.
  • Sixth commit (review): the de-duplication branch of the fourth commit attached its three helper columns (target and source row ids, the rank) under fixed names, and Dataset.withColumn replaces a same-named user column, so a source or target column called _duplicate_match_rank_, _target_row_id_ or _source_row_id_ was replaced and dropped and the clause expressions failed to bind. Each helper name is now generated to be absent from the joined sides under the session's resolver and dropped by that generated name; the fixed names stay the first candidate. The same commit runs the duplicate-match, helper-name and NULL not-matched-by-source tests with change data feed on as well, so the change rows written through the de-duplication window are compared to the CPU's too.
  • Seventh commit (review follow-up): the older helpers had the same fixed-name shape, here as in OSS Delta and the other shims: the row id and file name attached to the target in findTouchedFiles, the presence flag attached to each side of the join in writeAllChanges, and the row ids of the CDC delete de-duplication. They now use the same generator. The two control columns are different: the processors located _row_dropped_ by name in the output and the command dropped both control columns by name, so a user column with either name was read as the control column or dropped from the written data. The command now passes the control column's position (right after the target output columns, with or without CDC) through an optional field on RapidsProcessDeltaMergeJoin that defaults to the previous by-name lookup for the other shims, and drops the control columns by attribute, only when CDC put them in the output. The new CDC de-duplication test also found that the delete de-duplication with an insert clause aliased an untyped null literal for the row id the other side lacks, while the insert output carries it as a long, so the GPU processor could not concatenate the two outputs (pre-existing, same code in every shim); the literal is typed now. A merge row tracking test, the counterpart of the UPDATE and DELETE ones, is added in the same commit, marked as expected to fail because of the next item.
  • Eighth commit (found by that test): the command committed with the preserved-row-tracking tag but did not carry the target's materialized row id and commit version columns through the join, so every rewritten row came back with a fresh row id and the current commit version while the commit claimed the ids were preserved; the Databricks CPU merge keeps them. The two columns are now attached to the target side with the same helper the GPU UPDATE command uses and treated as the CPU does: copied rows keep both, updated rows (matched and not matched by source) keep the id and reset the commit version so the writer assigns the new one, inserted rows get both null. The processors rebuild their output attributes from the output schema, and the Delta writer recognises the two columns by the field metadata the helper puts on them, so that schema carries the metadata. Without row tracking the helper adds nothing. The test is un-marked in this commit.

Known limit, same as OSS Delta: with the clause present, findTouchedFiles records every target file, so the join-based merge rewrites the whole table. For a full-refresh source against a deep history table that is much more data than the row-index merge rewrites, so this does not replace a GPU low-shuffle merge for 17.3 (#11079); it makes the command GPU-owned so such a merge has somewhere to plug in.

Follow-ups not in this PR: delta-spark350db143 can take the same change; the delta-23x and delta-24x metas could drop their veto now that the shared processor accepts the clauses (delta-23x wraps the wrong lists for the fallback check at GpuMergeIntoCommand.scala:998-999, which would need fixing first).

Other shims that use the shared processor are unaffected by the clause change: their commands still pass empty clause lists, and an empty series takes the default projection, which is the previous no-op copy. They do pick up the NULL-condition fix of the fifth commit.

Areas worth a close look:

  • findTouchedFiles with the right outer join: multiple-match detection must stay keyed on matched rows only, which holds because target-only rows contribute exactly one row per target row id, and their effective-match predicate is null, so they count as no action.
  • The multiple-match rule (fourth commit): the error is raised on the count of pairs that take a WHEN MATCHED action, not on all joined pairs, and the de-duplication window in writeAllChanges only runs when findTouchedFiles saw target rows with several pairs of which at most one takes an action. The window partitions by the target row id and, for source-only rows, the source row id, so inserts pass through untouched.
  • Row tracking (eighth commit): the materialized columns are appended to targetOutputCols before the CDC de-duplication row ids, so the control-column position still counts them; the update expressions get (row id, null) from the Databricks helper and the insert expressions get two nulls, the copy and delete outputs use targetOutputCols as they are. The GPU scan still vetoes the row-tracking metadata fields, so on a row-tracked table the target read runs on the CPU and the rest of the merge on the GPU, as for UPDATE.
  • The control-column position (seventh commit): rowDroppedColumnIndex is the number of target output columns (row-id helpers included) at the point the schema is built, which is where every clause output appends ROW_DROPPED_COL, with or without CDC. The shared processor uses it only when set; the other shims pass nothing and keep the by-name lookup.
  • The NULL-as-false replacement (fifth commit) sits in splitBatchAndClose, so it also applies to the two presence predicates, which are never NULL, and to every matched and not-matched condition on every shim.
  • The per-clause-type metrics use And(incrTotal, incrByType); both sides are the metric UDF, so GpuAnd's side-effect path evaluates the right side on the rows where the left side is true, which is all of them.
  • The empty-batch path in the shared processor, new because a projection series over an empty split now returns nothing.
  • The clause output helpers were restructured (updateOutput, deleteOutput, insertOutput, clauseOutput) so matched and not-matched-by-source clauses share the update and delete expression builders; the matched and insert outputs are unchanged apart from the metric expression.

Tests:

  • test_delta_merge_not_matched_by_source (with and without CDF) now also runs on Databricks 17.3.
  • test_delta_merge_not_matched_by_source_db173_fallback is replaced by test_delta_merge_not_matched_by_source_db173, which exercises every row path (matched update, insert, not-matched-by-source update, untouched target rows) with the CPU expression bridge disabled and asserts GpuRapidsProcessDeltaMergeJoinExec is in the captured plan, so it fails if the command falls back.
  • test_delta_merge_not_matched_by_source_null_safe_keys adds a composite null-safe key with a boolean flag, the SCD type 2 shape.
  • test_delta_merge_not_matched_by_source_fallback is skipped on 17.3 like on OSS Delta 4.1.
  • test_delta_merge_delete_only_duplicate_source_metrics_db173 merges three source rows per key with an unconditional MATCHED DELETE and checks that numTargetRowsDeleted and numTargetRowsMatchedDeleted in the commit's operationMetrics both equal the rows deleted, on the CPU and the GPU (added after review: the matched-delete counter was not compensated for duplicate matches).
  • test_delta_merge_duplicate_source_rows_matched_conditions_db173 (added after review): six merges in which several source rows match a target row on ON but at most one satisfies a WHEN MATCHED condition (with and without NOT MATCHED BY SOURCE, with no WHEN MATCHED clause, with a conditional delete, with three matches), each with and without change data feed; the table (and the change rows) and the per-clause row counters must equal the CPU's, and the GPU processor must be in the plan.
  • test_delta_merge_duplicate_source_rows_ambiguous_error_db173 (added after review): three merges both engines must reject with DELTA_MULTIPLE_SOURCE_ROW_MATCHING_TARGET_ROW_IN_MERGE (two applying rows, each row taking a different clause, a target-only condition satisfied twice).
  • test_delta_merge_nullable_matched_conditions (added after review, all Delta versions): matched update and delete clauses and an insert clause whose conditions are NULL for some rows; the expected rows are spelled out and the CPU and GPU tables must both equal them.
  • test_delta_merge_duplicate_source_rows_helper_column_names_db173 (added after review): the reviewer's reproduction, a source with columns named _duplicate_match_rank_ and _source_row_id_, a target with _target_row_id_, two source rows matching one target row with one WHEN MATCHED condition satisfied and the clause actions reading those columns, with and without change data feed; table equal to the CPU's, GPU processor in the plan.
  • test_delta_merge_internal_column_names_db173 (seventh commit, with and without CDF): source and target columns named _row_id_ and _file_name_, read by the update and insert actions and by the not-matched-by-source condition; the expected rows are spelled out and the GPU processor is asserted in the plan. The presence-flag and control-column names are generated the same way but the Databricks CPU command rejects user columns with those names (missing or ambiguous attribute), so a parity test cannot use them.
  • test_delta_merge_control_column_names_gpu_db173 (seventh commit, with and without CDF, GPU only for the reason above): user columns named _row_dropped_ and _incr_row_count_ on both sides, read by every clause type; spelled-out rows, the GPU processor in the plan, and with CDF the change-row histogram (two pre-images, two post-images, one insert).
  • test_delta_merge_delete_only_duplicate_cdc_internal_column_names_db173 (seventh commit): an unconditional MATCHED DELETE with duplicate source matches, an insert clause and CDF on, with user columns named _target_row_id_ and _source_row_id_ read by the insert action; this is the test that reached the untyped null literal.
  • test_delta_merge_preserves_row_tracking_db173 (seventh commit): the merge counterpart of test_delta_update_preserves_row_tracking_db173 and the DELETE one. Every clause type touches a row-tracked target; the row ids of the rows that existed before survive on both engines, the commit version moves only for the updated rows, and the inserted row gets a fresh id. The ids are checked per row because the join-based merge lays out files differently from the CPU, which the log comparison of the UPDATE and DELETE tests does not tolerate.
  • test_delta_merge_not_matched_by_source_schema_evolution_db173: MERGE WITH SCHEMA EVOLUTION adding a source column, with a conditional NOT MATCHED BY SOURCE update; the updated and the copied target-only rows get NULL in the new column, the expected rows are spelled out, and the GPU processor is asserted in the plan.
  • test_delta_merge_nullable_not_matched_by_source_condition (added after review, OSS Delta 4.1 and Databricks 17.3+): the same plus a NOT MATCHED BY SOURCE condition that is NULL for target rows with a NULL column, with and without change data feed; GPU processor asserted in the plan. Without the fix the first test loses the four matched rows with a NULL flag and the second additionally the two target-only rows with a NULL column.

Validated on a Databricks 17.3 LTS ML cluster (Standard_NC16as_T4_v3, single node), built with jenkins/databricks/build.sh:

  • -k not_matched_by_source: 5 passed, 3 skipped (the two fallback parametrizations and the 14.3-only union test).
  • Whole delta_lake_merge_test.py: 93 passed, 0 failed, 4 skipped, 78 xfailed (the deletion vector parametrizations under [FEA] Need Deletion Vector read support for Databricks 17.3 #12042), 4 xpassed (test_delta_merge_disabled_fallback with DV on and a conf that already forces the CPU path, pre-existing).
  • After the leak fix: the overflow-error and not-matched-by-source tests again, 7 passed, no WAS LEAKED lines.
  • After the multiple-match change: the two new tests with the not-matched-by-source, delete-only metrics and overflow tests, 17 passed, 3 skipped (the fallback parametrizations), 0 failed, no WAS LEAKED lines; whole delta_lake_merge_test.py again: 103 passed, 0 failed, same skipped and xfailed set as before.
  • After the NULL-condition change: the same selection plus the two nullable-condition tests, 19 passed, 3 skipped, 0 failed, no WAS LEAKED lines.
  • After the helper-name change, with the duplicate-match, helper-name and NULL not-matched-by-source tests parametrized on CDF and the schema-evolution test added: the same selection, 29 passed, 3 skipped, 0 failed, no WAS LEAKED lines.
  • Seventh commit alone: the selection plus the internal-name, control-column and row tracking tests, 36 passed, 5 skipped, 1 xfailed (the row tracking test), 0 failed, no WAS LEAKED lines.
  • Seventh and eighth commits: the same selection, 37 passed, 5 skipped, 0 failed, no WAS LEAKED lines.

The Databricks build only runs on a Databricks cluster, so I could not run the plugin's scala unit tests for the 17.3 shim locally; happy to rerun anything the Databricks CI flags.

Performance Testing

Production-shaped merge from my SCD type 2 reproduction on public TPC-DS data (null-safe key, wide rows, materialized source, delete-detect clause), one job cluster per leg at the same seed.

Environment

  • Databricks 17.3 LTS ML, 1 driver + 1 worker Standard_NC16as_T4_v3
  • Same cluster spark conf on every leg (task gpu.amount 0.0625, concurrentGpuTasks 2, pinned pool 2G, optimizeWrite off, ANSI off); only the plugin jar or spark.rapids.sql.enabled differ
  • sf1000 with a 0.25 source sample: 3.0 M row target, 14.6 K changed keys

Results

Leg Command Merge Scan Rewrite
This build, plugin on GpuMergeIntoCommand 24.8 s 7 s 9 s
26.08.0, plugin on (the veto) CPU command, GPU internal jobs 52.9 s 5 s 29 s
Plugin off CPU command 68.1 s 5 s 35 s

All three legs rewrote the same 2,987,856 unchanged rows, so the comparison is on equal work. The driver log of the first leg shows MergeIntoCommandEdge and RapidsProcessDeltaMergeJoinExec will run on GPU and no fallback.

The merged table from the GPU command is identical to the CPU command's table on the same plugin-on cluster: same row count, same current, closed and flagged counts, same xor of xxhash64 over key, rowhash and flags. Against the plugin-off control the row set is the same and 117 of 2.9 M rows differ only in generated double attributes, which the GPU rounds differently at data generation time before the merge runs.

Checklists

Documentation

  • Updated for new or modified user-facing features or behaviors
  • No user-facing change

Testing

  • Added or modified tests to cover new code paths
  • Covered by existing tests
    (Please provide the names of the existing tests in the PR description.)
  • Not required

Performance

  • Tests ran and results are added in the PR description
  • Issue filed with a link in the PR description
  • Not required

…17.3 [databricks]

MERGE statements with WHEN NOT MATCHED BY SOURCE clauses fell back to the
CPU on Databricks 17.3 (NVIDIA#8415). The fallback costs more than the clause
itself: the DBR 17.3 CPU merge uses the row-index-set algorithm, whose
scan (hidden metadata columns, BitmapAggregator) and rewrite phases the
plugin cannot accelerate, so the whole merge runs on the CPU.

Extend the shared GPU merge-join processor to evaluate the not-matched-
by-source conditions on target rows without a source match. The existing
projection series already implements "first matching clause wins", so
the target-only rows now go through it with the no-op copy as the
default instead of being copied unconditionally.

In the DBR 17.3 GpuMergeIntoCommand, follow the OSS Delta 2.3 shape:
findTouchedFiles uses a right outer join and no target-only data
skipping when such clauses are present, writeAllChanges builds the
clause conditions and outputs (update, delete, CDC images) for the new
clause type, the insert-only shortcut is disabled when the clauses are
present, the row-based fallback processor handles the clauses the same
way, the commit records the clause predicates, and the per-clause-type
update and delete metrics reported by the CPU command are added. Remove
the veto in the DBR 17.3 MergeIntoCommandMetaShim; the deletion vector
veto is unchanged.

Enable the existing not-matched-by-source integration test on
Databricks 17.3, turn the DBR 17.3 fallback test into a test that
asserts the GPU merge processor is in the plan, and add a composite
null-safe key case.

Signed-off-by: Thomas Wynne <jtwynne3@gmail.com>
…rows [databricks]

In GpuRapidsProcessDeltaMergeJoinIterator.processProjectionSeries the
closeOnExcept guard wrapped the batch that splitBatchAndClose had already
closed, while the not-matched remainder produced by the split was left
open. When a clause projection throws (for example the
CheckOverflowInTableWrite error path exercised by
test_delta_merge_check_overflow_in_table_write_error on Databricks 17.3)
the remainder leaked and the cleaner thread reported the joined batch's
columns as leaked device column vectors.

Guard the not-matched remainder instead of the consumed input.

Signed-off-by: Thomas Wynne <jtwynne3@gmail.com>
@greptile-apps

greptile-apps Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds GPU support for Databricks 17.3 MERGE statements containing WHEN NOT MATCHED BY SOURCE.

  • Evaluates target-only clause series with SQL-correct NULL handling and exception-safe GPU resource cleanup.
  • Extends the Databricks merge command with clause-specific outputs, metrics, CDC handling, duplicate-match semantics, collision-safe helper columns, and preserved row-tracking metadata.
  • Removes the Databricks 17.3 fallback veto while retaining the persistent deletion-vector veto.
  • Adds extensive CPU/GPU parity, execution-plan, metrics, CDC, schema-evolution, duplicate-match, internal-column-name, nullable-condition, and row-tracking coverage.

Confidence Score: 5/5

The PR appears safe to merge, with the previously reported matched-delete metric overcount corrected and no new actionable findings.

The current implementation compensates both aggregate and matched-delete counters for duplicate unconditional deletes, and the added coverage exercises the new clause paths, duplicate-match rules, NULL semantics, CDC output, helper-column collisions, schema evolution, and row tracking.

Important Files Changed

Filename Overview
delta-lake/common/src/main/scala/com/nvidia/spark/rapids/delta/GpuRapidsProcessDeltaMergeJoinExec.scala Adds target-only clause processing, NULL-as-false filtering, positional control-column handling, empty-output support, and safer remainder cleanup.
delta-lake/delta-spark400db173/src/main/scala/com/databricks/sql/transaction/tahoe/rapids/GpuMergeIntoCommand.scala Implements Databricks 17.3 NOT MATCHED BY SOURCE execution with duplicate-match handling, CDC, metrics, collision-safe helpers, and row-tracking preservation.
delta-lake/delta-spark400db173/src/main/scala/com/nvidia/spark/rapids/delta/shims/MergeIntoCommandMetaShim.scala Removes the obsolete GPU fallback restriction for NOT MATCHED BY SOURCE clauses.
integration_tests/src/main/python/delta_lake_merge_test.py Adds broad Databricks 17.3 CPU/GPU parity and GPU-plan verification for the newly supported merge behavior and associated edge cases.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Source and target rows] --> B[Join using MERGE condition]
  B --> C{Row category}
  C -->|Matched pair| D[First matching WHEN MATCHED clause]
  C -->|Source only| E[First matching WHEN NOT MATCHED clause]
  C -->|Target only| F[First matching WHEN NOT MATCHED BY SOURCE clause]
  D --> G[Update, delete, or copy]
  E --> H[Insert or discard]
  F --> I[Update, delete, or copy]
  G --> J[Apply CDC, metrics, and row tracking]
  H --> J
  I --> J
  J --> K[Write rewritten Delta files and commit]
Loading

Reviews (14): Last reviewed commit: "Merge branch 'main' into db173-merge-not..." | Re-trigger Greptile

…databricks]

An unconditional MATCHED DELETE is the only merge that allows several
source rows to match the same target row. The delete counters are
incremented once per joined pair and the duplicate count is subtracted
afterwards, but the Databricks 17.3 command only compensated
numTargetRowsDeleted, so the new numTargetRowsMatchedDeleted metric
was committed with the overcount. Apply the same compensation to the
matched-delete counter, as OSS Delta and the delta-24x command do.

Add test_delta_merge_delete_only_duplicate_source_metrics_db173, which
merges three source rows per key with an unconditional MATCHED DELETE
and checks that both counters in the commit's operationMetrics equal
the number of target rows deleted, on the CPU and on the GPU.

Signed-off-by: Thomas Wynne <jtwynne3@gmail.com>
@jtwynne

jtwynne commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Good catch! OSS Delta and the delta-24x command compensate both counters and I carried over only the first. Fixed in 620ba54f4 (the duplicate-match compensation now also adjusts numTargetRowsMatchedDeleted). Added test_delta_merge_delete_only_duplicate_source_metrics_db173, which merges three source rows per key with an unconditional MATCHED DELETE and asserts that both numTargetRowsDeleted and numTargetRowsMatchedDeleted in the commit's operationMetrics equal the number of rows deleted, on the CPU and on the GPU. Ran it on the 17.3 cluster together with the NOT MATCHED BY SOURCE and overflow tests: 8 passed, 3 skipped (the fallback parametrizations), no leak reports. Without the compensation the test's 150 joined pairs would leave numTargetRowsMatchedDeleted at 150 for 50 deleted rows. (updated PR description)

@sameerz
sameerz requested review from a team and gerashegalov September 3, 2026 14:16
@sameerz sameerz added the feature request New feature or request label Sep 3, 2026
// Target rows without a source match are handled by the NOT MATCHED BY SOURCE clauses.
// A target row that satisfies none of the clause conditions is copied unchanged.
val targetNotMatchedBatches = closeOnExcept(targetMatchBatch) { _ =>
processProjectionSeries(targetNoMatchBatch,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processProjectionSeries partitions rows using predicate and predicate.not(). If a nullable NOT MATCHED BY SOURCE condition evaluates to NULL, both cuDF filter masks exclude the row because NOT NULL remains NULL. The row consequently reaches neither the clause output nor noopCopyOutput, silently removing an unmatched target row. Could we coalesce each condition to false before splitting and add a nullable-condition test? SQL MERGE should preserve the target row when no clause condition evaluates to true.

@jtwynne jtwynne Sep 4, 2026

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.

Gotcha! And it looks like it isn't specific to the new clause. Every condition goes through the same split, so a WHEN MATCHED AND s.flag with a NULL flag was deleting the target row on the GPU where the CPU copies it. Fixed in ded963e, one level below these lines in splitBatchAndClose (the one place every clause type passes through) rather than just on the not-matched-by-source conditions: NULLs in the evaluated condition get replaced with false before the two filters (masks without NULLs are used as-is), so the row falls through to the next clause or the default the same way the CPU row processor does.

Tests: test_delta_merge_nullable_matched_conditions (all Delta versions; matched update and delete clauses plus an insert clause with NULL conditions, expected rows spelled out) and test_delta_merge_nullable_not_matched_by_source_condition (OSS 4.1 and 17.3+; adds a NOT MATCHED BY SOURCE condition that's NULL for target rows with a NULL column, GPU processor asserted in the plan). Without the fix the first one loses the four matched rows with a NULL flag and the second also loses the two target-only rows with a NULL column.

.withColumn(FILE_NAME_COL, input_file_name())
val joinToFindTouchedFiles =
sourceDF.join(targetDF, DFUDFShims.exprToColumn(condition), "inner")
sourceDF.join(targetDF, DFUDFShims.exprToColumn(condition), joinType)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DBR 16.0+ uses both the ON condition and WHEN MATCHED conditions when determining whether multiple source rows ambiguously match one target row. This join is subsequently grouped by target row ID without filtering through the matched-clause conditions, so it appears to retain the older OSS ON-only behavior. For example, two source rows may satisfy ON while only one satisfies WHEN MATCHED AND source.apply_update; DBR 17.3 CPU should accept that merge, while this path reports multiple matches. Since this PR makes queries containing NOT MATCHED BY SOURCE GPU-eligible, those queries no longer fall back to CPU. Could we align the duplicate-match predicate with DBR 17.3 and add a CPU/GPU parity test for this case?

@jtwynne jtwynne Sep 4, 2026

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.

Yes good call! The 17.3 command has been using the OSS ON-only count for every merge it runs, this PR just makes it easier to hit. Fixed in eb8f690.

  • findTouchedFiles now counts, per target row, the pairs that satisfy a WHEN MATCHED condition (no condition = true) and only raises the error when more than one does. The unconditional-delete exception is unchanged.
  • The join-based write needs one extra step the CPU row-index merge doesn't. When a target row has several joined pairs and at most one of them takes an action, writeAllChanges keeps one pair per target row (the one that takes the action if there is one) using a row_number window over the target and source row ids. The pairs it drops are matched source rows that don't take an action, so they don't get inserted or applied, and a row with no applying pair gets copied once. The window only runs when findTouchedFiles actually found rows like that.

I checked what DBR 17.3 CPU does first (default and enableLowShuffle=false) and turned those shapes into the parity tests: test_delta_merge_duplicate_source_rows_matched_conditions_db173 (six accepted shapes, including no WHEN MATCHED clause and a conditional delete; table and per-clause counters have to match the CPU, GPU processor asserted in the plan) and test_delta_merge_duplicate_source_rows_ambiguous_error_db173 (three shapes both engines reject with DELTA_MULTIPLE_SOURCE_ROW_MATCHING_TARGET_ROW_IN_MERGE). Ran them on a 17.3 cluster with the rest of the merge tests: 17 passed, 3 skipped, 0 failed, no leak reports.

@gerashegalov

Copy link
Copy Markdown
Collaborator

Thanks for this PR @jtwynne

…ks 17.3 [databricks]

Databricks Runtime 16.0 and later report several source rows matching
the same target row as an error only when more than one of them
satisfies the ON condition and a WHEN MATCHED clause condition (an
undefined condition is implicitly true). Source rows that match on ON
alone take no action: they are not inserted, they do not flag the row
for the NOT MATCHED BY SOURCE clauses, and they do not make it
ambiguous. The Databricks 17.3 GPU command still counted every joined
pair, the OSS ON-only rule, so a merge the CPU accepts failed on the
GPU; with NOT MATCHED BY SOURCE merges no longer falling back, that
gap now reaches more queries.

findTouchedFiles counts, per target row, the joined pairs and the
pairs that take a WHEN MATCHED action, and raises the multiple-match
error only for the second count (the unconditional-delete exception
is unchanged). When some target rows have several joined pairs of
which at most one takes an action, writeAllChanges keeps one pair per
target row, preferring the one that takes the action, with a
row_number window over target and source row ids; the dropped pairs
are matched source rows that take no action, and when none does the
surviving pair copies the target row unchanged. The window only runs
when such rows exist, so merges without duplicate matches are
unchanged.

Add test_delta_merge_duplicate_source_rows_matched_conditions_db173
(six accepted shapes: one or no effective match with and without NOT
MATCHED BY SOURCE, no WHEN MATCHED clause, a conditional delete, three
matches with one effective; the tables and the per-clause row counters
must match the CPU) and
test_delta_merge_duplicate_source_rows_ambiguous_error_db173 (three
shapes both engines reject: two effective matches, each row taking a
different clause, a target-only condition).

Signed-off-by: Thomas Wynne <jtwynne3@gmail.com>
…tabricks]

A MERGE clause condition that evaluates to NULL is false: the row moves
on to the next clause or to the default action, which copies a target
row unchanged or skips a source row. The shared GPU merge processor
splits each batch with the condition and with its negation through
cuDF filters, and a NULL mask entry passes neither (NOT NULL is NULL),
so a row with a NULL condition was silently dropped from the output.
For matched rows that turned a "WHEN MATCHED AND s.flag" with a NULL
flag into a deleted target row; with NOT MATCHED BY SOURCE clauses the
same happens to target-only rows. The CPU row processor evaluates such
a condition as false. Replace NULLs in the evaluated condition with
false before splitting, in the one place every clause type goes
through; masks without NULLs are used as they are.

Add test_delta_merge_nullable_matched_conditions (all Delta versions:
matched update and delete clauses and an insert clause whose
conditions are NULL for some rows; expected rows spelled out, CPU and
GPU tables compared) and
test_delta_merge_nullable_not_matched_by_source_condition (OSS Delta
4.1 and Databricks 17.3+: the same plus a NOT MATCHED BY SOURCE
condition that is NULL for target rows with a NULL column, GPU
processor asserted in the plan).

Signed-off-by: Thomas Wynne <jtwynne3@gmail.com>
@jtwynne
jtwynne requested a review from gerashegalov September 4, 2026 21:25
Comment on lines +800 to +803
rawJoinedDF
.withColumn(DUPLICATE_MATCH_RANK_COL, row_number().over(onePairPerTargetRow))
.filter(col(DUPLICATE_MATCH_RANK_COL) === lit(1))
.drop(DUPLICATE_MATCH_RANK_COL, TARGET_ROW_ID_COL, SOURCE_ROW_ID_COL)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper is added with the fixed name _duplicate_match_rank_:

Dataset.withColumn replaces an existing column with the same name. If the source has a column named _duplicate_match_rank_, this projection replaces it with the window result, and the subsequent drop removes it completely. MERGE action expressions still reference the original source attribute, so the GPU
processor later fails to bind that expression.

I reproduced this on DBR 17.3 with two source rows matching the same target row, where only one satisfies the WHEN MATCHED condition:

-- source:
-- (k=1, apply=true,  _duplicate_match_rank_='chosen')
-- (k=1, apply=false, _duplicate_match_rank_='ignored')
-- (k=4, apply=true,  _duplicate_match_rank_='inserted')

MERGE INTO target t
USING source s
ON t.k = s.k
WHEN MATCHED AND s.apply THEN
  UPDATE SET t.v = s._duplicate_match_rank_
WHEN NOT MATCHED THEN
  INSERT (k, v) VALUES (s.k, s._duplicate_match_rank_)

The DBR CPU command succeeds, producing chosen for key 1 and inserted for key 4. The GPU command instead aborts with:

Couldn't find _duplicate_match_rank_#140 in
[k#138, apply#139, _source_row_present_#779,
 k#476, v#477, _target_row_present_#781]

This duplicate pattern is supported by DBR 16+ because duplicate-match detection considers both the ON condition and WHEN MATCHED conditions:

https://docs.databricks.com/aws/en/delta/merge

_duplicate_match_rank_ is also a valid Databricks identifier: ordinary identifiers may contain letters, digits, and underscores:

https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-identifiers

Databricks advises avoiding underscore-prefixed names because they may be used for pseudo-columns, but it does not reserve this name or reject such columns.

We could rename the constant to another fixed string with a more specific prefix like cudf_spark_delta_merge_duplicate_match_rank to make the collision less likely. But we also can make this robust:

  • A helper name generated to be absent from rawJoinedDF under Spark's configured name resolver.
  • A Catalyst Alias with a fresh ExprId, followed by removal of that exact generated attribute instead of removal by string name.

@jtwynne jtwynne Sep 6, 2026

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.

Fixed in 108cf7c: each of the three helper names is generated to be absent from the joined sides under the session's resolver (conf.resolver, so case sensitivity follows the session), and the helpers are dropped by those generated names. The fixed names stay the first candidate, so plans without a clash are unchanged. Added test_delta_merge_duplicate_source_rows_helper_column_names_db173: your repro plus a _source_row_id_ column on the source and a _target_row_id_ column on the target, all three read by the clause actions; the table has to match the CPU and the GPU processor has to be in the plan.

This one made me step back and look at what else the port inherited without checking, since the last few rounds were all places where DBR and OSS Delta differ. I went through the DBR 15.0 to 17.3 release notes for MERGE changes and held them against the command: the 16.x multiple-match rule is the earlier commit, source materialization goes through DBR's MergeIntoMaterializeSource, the 17.0 non-deterministic action values are only evaluated once in the write pass, DV tables are vetoed. Nothing else in the code, but two holes in the tests, both in the same commit and green on 17.3: the duplicate-match, helper-name and NULL not-matched-by-source tests now run with CDF on too (the change rows come out of the same window), and test_delta_merge_not_matched_by_source_schema_evolution_db173 covers MERGE WITH SCHEMA EVOLUTION with the new clause. One more thing this made me check: 17.2 turned row tracking on by default for new managed tables, and the merge command had no row tracking test where UPDATE and DELETE do (see the end of the next paragraph).

The older helpers had the same fixed-name shape (_row_id_ and _file_name_ in findTouchedFiles, the two presence flags, the CDC de-dup row ids), here as in OSS Delta and the other shims, so rather than leave them for a follow-up I moved them onto the same generator in bbfabf8. The two control columns needed something else: the processors find _row_dropped_ by name in the output and the command dropped both by name, so a user column with that name was read as the control column or dropped from the written data. The command now passes the position it knows (right after the target output columns, with or without CDC) through an optional field on RapidsProcessDeltaMergeJoin that defaults to the old lookup for the other shims, and drops the control columns by attribute, only when CDC put them in the output. Tests: test_delta_merge_internal_column_names_db173 (_row_id_ and _file_name_ as user columns on both sides, with and without CDF), test_delta_merge_control_column_names_gpu_db173 (_row_dropped_ and _incr_row_count_ as user columns, GPU only against spelled-out rows, because the DBR CPU command rejects user columns named like its presence flags or its _row_dropped_; the presence-flag names are generated the same way and stay untested for that reason) and test_delta_merge_delete_only_duplicate_cdc_internal_column_names_db173 for the CDC delete de-dup row ids. The CDC de-dup test also tripped over something older: the delete de-dup with an insert clause aliases an untyped null for the row id the other side lacks, the insert output carries it as a long, and the GPU processor can't concatenate the two, so that literal is typed now (same code in every shim, never reached by a GPU-processor test before). The row tracking question is answered too, and it was a real one: test_delta_merge_preserves_row_tracking_db173, the merge counterpart of the UPDATE and DELETE ones, showed the command committing with the preserved-row-tracking tag while every rewritten row came back with a fresh id. 2a1669b fixes it the way the GPU UPDATE does: the two materialized columns ride through the join (copied rows keep both, updated rows keep the id and reset the version, inserts get nulls), with the field metadata the Delta writer keys on. I first read the OSS 3.3 and 4.x commands as having the same gap and opened #15906 for it, but they inherit OSS Delta's ClassicMergeExecutor.writeAllChanges, which does preserve the columns, so I have re-scoped that issue to the test the OSS shims are missing. Those are now #15907, run on Spark 3.5.6 / Delta 3.3.0, 4.0.0 / Delta 4.0.1 and 4.1.1 / Delta 4.1.0 on a T4 box (three passed each); kept separate because they are OSS-only and this PR is the Databricks shim.

…databricks]

The de-duplication of non-effective duplicate matches attached three
helper columns under fixed names: the target and source row ids and
the row_number rank. Dataset.withColumn replaces an existing column of
the same name, so a source or target column called
_duplicate_match_rank_, _target_row_id_ or _source_row_id_ was
replaced by the helper and then dropped with it, and the clause
expressions that referenced the user column failed to bind in the GPU
merge processor. The Databricks CPU command accepts such columns.

Pick each helper name so that no column of the joined sides resolves
to it under the session's resolver, and drop the helpers by those
generated names. The fixed names stay the first choice, so plans
without a clash are unchanged.

Add test_delta_merge_duplicate_source_rows_helper_column_names_db173:
a source with columns named _duplicate_match_rank_ and _source_row_id_
and a target with _target_row_id_, two source rows matching one target
row with one WHEN MATCHED condition satisfied, the clause actions
reading those columns; the table must equal the CPU's and the GPU
processor must be in the plan.

Run this test, the duplicate-match accepted cases and the NULL
not-matched-by-source condition test with change data feed on as well,
so the change rows produced through the de-duplication window are
compared to the CPU's too.

Add test_delta_merge_not_matched_by_source_schema_evolution_db173:
MERGE WITH SCHEMA EVOLUTION adds a source column while a conditional
NOT MATCHED BY SOURCE clause updates target-only rows; the updated and
the copied target-only rows get NULL in the new column.

Signed-off-by: Thomas Wynne <jtwynne3@gmail.com>
The sixth commit generated the names of the three helpers the
duplicate-match de-duplication attaches. The older helpers of the
Databricks 17.3 GPU merge command had the same shape: the row id and
file name attached to the target in findTouchedFiles, the presence
flag attached to each side of the join in writeAllChanges, and the row
ids of the CDC delete de-duplication. Dataset.withColumn replaced a
user column of the same name, and a name present on both sides was
ambiguous after the join.

The two control columns are different: the processors locate
_row_dropped_ by name in the output, and the command dropped both
control columns by name, so a user column named _row_dropped_ or
_incr_row_count_ was either read as the control column or dropped
from the written data.

Generate every attached helper name with uniqueColumnName. Pass the
position of the row-dropped control column to the processors as the
command knows it (right after the target output columns, with or
without CDC), with an optional field on RapidsProcessDeltaMergeJoin
that defaults to the previous by-name lookup for the other shims. Drop
the control columns by attribute, only when CDC put them in the
output.

The CDC delete de-duplication with an insert clause also aliased an
untyped null literal for the row id that the other side of the join
does not have. The insert output carries that row id as a long, so the
GPU processor could not concatenate the two outputs. The literal is
typed as a long now.

Add test_delta_merge_internal_column_names_db173 (with and without
CDF): source and target columns named _row_id_, _file_name_,
_row_dropped_ and _incr_row_count_, read by the update, insert and
not-matched-by-source actions and the not-matched-by-source condition;
the expected rows are spelled out and the GPU processor must be in the
plan. The Databricks CPU command rejects user columns named like the
presence flags, so a parity test cannot use those two names. Add
test_delta_merge_delete_only_duplicate_cdc_internal_column_names_db173
for the CDC delete de-duplication with user columns named
_target_row_id_ and _source_row_id_.

Add test_delta_merge_preserves_row_tracking_db173, the merge
counterpart of the UPDATE and DELETE row tracking tests: the row ids
of the rows that existed before the merge survive on both engines,
the commit version moves only for the updated rows, and the inserted
row gets a fresh id. The ids are checked per row because the
join-based merge lays out files differently from the CPU.

Signed-off-by: Thomas Wynne <jtwynne3@gmail.com>
The command committed with the preserved-row-tracking tag but did not
carry the target's materialized row id and commit version columns
through the join, so every rewritten row came back with a fresh row
id and the current commit version while the commit claimed the ids
were preserved. The Databricks CPU merge keeps the ids of copied and
updated rows.

Attach the two materialized columns to the target side with the same
helper the GPU UPDATE command uses and treat them like the CPU does:
copied rows keep both, updated rows (matched and not matched by
source) keep the id and reset the commit version so the writer
assigns the new one, inserted rows get both null. The processors
rebuild their output attributes from the output schema, and the Delta
writer recognises the two columns by the field metadata the helper put
on them, so the schema carries that metadata. Without row tracking the
helper adds nothing and the plan is unchanged.

test_delta_merge_preserves_row_tracking_db173, added by the previous
commit as expected to fail, now passes: the row ids of the rows that
existed before survive on both engines, the commit version moves only
for the updated rows, and the inserted row gets a fresh id.

Signed-off-by: Thomas Wynne <jtwynne3@gmail.com>
jtwynne added a commit to jtwynne/cudf-spark that referenced this pull request Sep 7, 2026
… skip it on Databricks

test_delta_merge_preserves_row_tracking drove the CPU and GPU sessions by hand with its own plan
capture. It now runs through assert_gpu_and_cpu_writes_are_equal_collect, the path the UPDATE
and DELETE tests already take: for a Delta test the helper runs the GPU side under
assert_rapids_delta_write, which asserts the GPU Delta write that only the GPU merge command
produces, then reads both tables back on the CPU with the row tracking columns and compares
them engine to engine (the inserted row's id masked, since the file layout decides it). The
merge's result row and the per-engine row id and commit version checks stay.

The capability predicate includes Databricks 17.3, but the 17.3 GPU merge on main regenerates
row ids (NVIDIA#15884 fixes that and carries the 17.3 test), so the MERGE test is skipped on
Databricks with that reason. The UPDATE and DELETE tests keep running on 17.3.

Signed-off-by: Thomas Wynne <jtwynne3@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature request New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants