Skip to content

feat: add native support for MergeRowsExec (row-level MERGE INTO) - #5318

Merged
andygrove merged 7 commits into
apache:mainfrom
unikdahal:mergerows-operator-generic
Oct 4, 2026
Merged

andygrove merged 7 commits into
apache:mainfrom
unikdahal:mergerows-operator-generic

Conversation

@unikdahal

@unikdahal unikdahal commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Part of #5122.

Rationale for this change

Row-level MERGE plans currently fall back to Spark entirely because MergeRowsExec has no
native equivalent, even though the expensive parts of the plan (the source/target join, filters,
projections) are operators Comet already accelerates.

MergeRowsExec itself is close to a projection: it routes each joined row through matched /
not-matched / not-matched-by-source instruction lists that are ordinary Catalyst expressions,
emitting kept, inserted, or discarded rows.

Adding a native implementation allows the join and merge dispatch logic to remain in a native
Comet stage instead of falling back to Spark for the whole statement.

This is independent of native writes: MergeRowsExec can remain native while the final Iceberg
write is still performed by the JVM writer, and can also be combined with Comet's native Iceberg
writer when enabled.

What changes are included in this PR?

  • New native MergeRowsExec operator
    (native/core/src/execution/operators/merge_rows.rs) reproducing Spark's dispatch semantics:
    • matched / not-matched / not-matched-by-source instruction groups
    • Keep / Discard / Split semantics (0/1/2 output projections)
    • first-match-wins clause evaluation
    • Spark's NULL-collapses-to-false predicate semantics
  • Cardinality checking (MERGE_CARDINALITY_VIOLATION), mirroring Spark's
    BitmapCardinalityValidator, surfaced as a real SparkRuntimeException through the structured
    SparkError / JNI error-conversion path rather than as a generic native exception.
  • New MergeRows / MergeInstruction / MergeOutputRow protobuf messages.
  • CometOperatorSerde support for MergeRowsExec, version-gated for supported Spark versions
    (the operator does not exist before Spark 3.5).
  • New spark.comet.exec.mergeRows.enabled configuration, disabled by default
    (experimental / opt-in).
  • output_rows / output_batches / elapsed_compute metrics.
  • Documentation updates covering operator support and compatibility/caveats.

Spark 4.x's eight per-clause row counters
(numTargetRowsCopied, numTargetRowsInserted, numTargetRowsUpdated,
numTargetRowsDeleted, etc.) are not currently exposed by the native operator.
This is documented as a known compatibility gap; Spark 3.5.x does not expose these
metrics either.

Performance

I benchmarked an Iceberg copy-on-write mixed-CDC MERGE workload across three execution paths:

Execution path Best time (ms) Avg time (ms) Stdev (ms) Rate (M rows/s) Per row (ns) Relative
Spark (Comet disabled) 1632 1687 50 0.6 1813.8 1.0x
Comet MergeRowsExec + JVM Iceberg writer 1241 1257 14 0.7 1378.7 1.3x
Comet MergeRowsExec + native Iceberg writer 1018 1054 38 0.9 1131.2 1.6x

Environment

  • OpenJDK 64-Bit Server VM 17.0.20+8-LTS
  • Linux 6.17.0-1022-azure
  • AMD EPYC 7763 64-Core Processor

The JVM-writer comparison isolates the benefit of native MergeRowsExec: with the final
Iceberg write still performed by Spark's JVM writer, keeping merge processing in Comet improves
the benchmark from 1632 ms to 1241 ms best time, approximately 1.3x relative to the Spark
baseline
.

When MergeRowsExec is combined with Comet's native Iceberg writer, the workload improves from
1632 ms to 1018 ms best time, approximately 1.6x relative to the Spark baseline.

The benchmark harness was used for performance validation only and is not included as part of
this PR.

How are these changes tested?

  • Rust unit tests in merge_rows.rs covering:

    • matched / not-matched / not-matched-by-source dispatch
    • first-match-wins ordering
    • NULL handling
    • cardinality violations, including violations split across batches and NULL row IDs
    • Split output-row values
    • out-of-range row-ID ordinal rejection
  • CometMergeRowsSuite (Scala), using Spark's
    InMemoryRowLevelOperationTableCatalog so the core MergeRowsExec contract is tested
    independently of Iceberg or another connector:

    • asserts native Comet engagement
    • verifies exact Comet/Spark result parity
    • verifies cardinality violations surface as
      SparkRuntimeException[MERGE_CARDINALITY_VIOLATION], matching Spark's exception type
      and error class
  • End-to-end validation against Iceberg covering:

    • MATCHED
    • NOT MATCHED
    • NOT MATCHED BY SOURCE
    • update / delete / insert
    • multiple clauses and first-match-wins behavior
    • NULL conditions
    • cardinality violations

The end-to-end validation confirms native engagement and result/error parity with Spark, while
the benchmark above provides performance evidence both with Spark's JVM Iceberg writer and with
Comet's native Iceberg writer.

@unikdahal

Copy link
Copy Markdown
Contributor Author

@peterxcli @andygrove @jordepic ready for review whenever you have time.

@jordepic

Copy link
Copy Markdown
Contributor

Thanks for doing this @unikdahal ! I won't be the best source of truth since this is just the default merge operator as opposed to the iceberg stuff. My hope is that once this is in supporting the MERGE INTO operator in iceberg is pretty trivial.

I think @andygrove will need to provide some more feedback on how much the merge operator here can deviate from default spark functionality, I know the intent of comet is to be perfectly identical, but can deviate with some opt-in toggles.

sunchao
sunchao previously approved these changes Aug 23, 2026

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Both existing [P2] findings still apply to this head and need to be addressed before merge: later predicates evaluate already-handled rows and nested output types are not normalized before stamping. This approval does not mark either finding resolved.

The focused exact-head native probes reproduced both failures while the corresponding Spark 4.0.4 MERGE statements succeeded. GitHub CI is still marked action_required, so there is no successful full CI result for this head.

@sunchao
sunchao dismissed their stale review August 23, 2026 04:09

Withdrawing this approval because I approved the wrong PR in error. The existing P1/P2 findings remain unresolved and still need to be addressed before merge.

@unikdahal
unikdahal requested a review from sunchao August 23, 2026 10:50

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two follow-up P2 findings on 7f378fb6. All 15 existing native MergeRows tests passed. The subquery finding was checked with a successful Spark 4.1.3 baseline and an expression-discovery probe compiled from the new class against cached Comet dependencies; a full Comet JVM run was blocked by Maven dependency resolution. The memory finding was reproduced using this revision's exact check_cardinality function and a 16 MiB memory pool.

@unikdahal

Copy link
Copy Markdown
Contributor Author

Thanks @sunchao for the detailed review. I rebased this PR onto the latest main and addressed the remaining review findings.

The current head includes the following fixes:

  • Later predicates evaluating already-handled rows: run_group now physically narrows the working batch after each matched instruction, so rows claimed by an earlier clause are never evaluated by later predicates. The ANSI divide-by-zero case from the review is covered by a regression test.

  • Nested output nullability: projected output now goes through cast_and_stamp_schema, matching the approach used by ExpandExec, so nested nullability/type widening is normalized before constructing the output batch. The reproduced nested-struct case is covered by a regression test.

  • MERGE assignment subqueries: CometMergeRowsExec now retains the source/target predicates and the three instruction groups as actual SparkPlan expression fields instead of hiding them under originalPlan. This lets Catalyst/Comet discover and prepare scalar subqueries correctly while also preserving MATCHED / NOT MATCHED / NOT MATCHED BY SOURCE boundaries for plan equality and canonicalization. Added an end-to-end scalar-subquery assignment test and semantic equality/canonicalization coverage.

  • Cardinality hash-table memory accounting: cardinality state now uses DataFusion's hash table implementation and reserves memory with the DataFusion memory pool before growing the table. Accounting includes hash-table capacity/allocation overhead rather than charging a fixed number of bytes per inserted ID, and reservation is rolled back if allocation fails. The exact 917,505-ID / 16 MiB reproduction from the review is covered, along with rehash-boundary and actual-allocation accounting tests.

I also added/strengthened coverage for native WriteDelta execution and Split update-as-delete+reinsert semantics, required native engagement in the cardinality-error test, validated the row-ID type/ordinal up front, and kept MERGE_CARDINALITY_VIOLATION mapped to Spark's structured runtime error.

Would appreciate another look when you have time. Thanks again for the precise reproductions, they helped tighten this substantially.

@andygrove

Copy link
Copy Markdown
Member

Thanks for this, and thanks @sunchao for the earlier rounds. I went through the current head (e8af41e48) and all four of the previous P2 findings look genuinely addressed: run_group physically shrinks the working batch between instructions, project goes through cast_and_stamp_schema, the instruction groups are real case-class fields so prepareSubqueries can find scalar subqueries, and the cardinality set reserves against estimate_memory_size at rehash boundaries. I also checked the null-row-id handling against UnsafeRow.setNullAt semantics and it is correct, and confirmed the MergeRows.Instruction API (condition, outputs, ROW_ID) is identical in 3.5.9 and 4.1.0. Nice piece of work.

Relationship to #5122

The scoping is right. #5122 covers four operators (MergeRowsExec, ReplaceDataExec, WriteDeltaExec, and InsertOnlyMergeExec on 4.2+), and this takes only the first, independent of native writes, which is exactly what the issue proposed.

Two things worth naming:

InsertOnlyMergeExec is correctly out of scope here, but on Spark 4.2 an insert-only MERGE plans that instead of MergeRowsExec, so this operator will silently not engage. A follow-up issue under #5122 would be better than expanding this PR.

@peterxcli asked to be tagged for review on the issue thread.

Findings

1. Rebase and get a green CI run. The branch conflicts with main in two places. operator.proto field 120 is now IcebergWrite iceberg_write = 120, so MergeRows needs to move to 121, and operators/mod.rs conflicts too. main also moved to DataFusion 55.0 and Arrow 59.2 in 75fdddc92. gh pr checks currently reports no checks at all on this head. A 2400-line change touching the planner, the proto, error mapping and three Spark profiles really needs a full build before it can be evaluated.

2. CometMergeRows.scala is byte-for-byte identical in spark-3.5/ and spark-4.x/. I diffed them and the only difference is the path. Same for ShimCometMergeRows.scala and the three-line ShimSparkErrorConverter case. That is 174 lines of real fallback logic that has to be kept in sync by hand across four Spark profiles. ShimCometWindowGroupLimit sets a precedent for duplication, but that is a 20-line shim, not a serde. The spark-4.1+ source root with the spark-none placeholder is the existing pattern for a shared "3.5 and later" root. Could the serde move there, leaving only the thin class-registration shim per version?

3. getSupportLevel returns Compatible(None) while three divergences are documented. The one that stands out is metrics. On Spark 4.1+, MergeRowsExec publishes eight per-clause counters (numTargetRowsCopied, numTargetRowsInserted, numTargetRowsUpdated, numTargetRowsDeleted, and the four matched / not-matched-by-source breakdowns). Enabling this operator replaces all of them with generic Comet metrics, and 4.1 is the default build profile. Should this be Incompatible(Some(...)) with a matching getIncompatibleReasons() so it surfaces on the compat page? Implementing the counters may not be much work either: the proto already has a per-instruction message and run_group already computes fire.true_count() per instruction, so a Context enum on MergeInstruction plus per-instruction counts would cover it. Also, the doc says "Spark 4.x's MergeRowsExec exposes eight metrics" but 4.0.1 has none. They arrived in 4.1 alongside the Context field on Keep.

4. Output row order diverges from Spark and is not on the compat page. run_group emits rows grouped by the instruction that produced them, and process_batch concatenates matched, then not-matched, then not-matched-by-source. Spark emits in input row order. The Rust doc comment argues this is safe because DistributionAndOrderingUtils places the required repartition and sort above MergeRows, which holds for a connector that declares one. A V2 table that declares neither ends up with a different physical row order in the written files than Spark produces. That belongs on compatibility/operators.md alongside the other two caveats, since it is what a user would actually notice from an unordered SELECT *.

5. Could you add some benchmark numbers? The rationale is that MERGE falls back today, but with the writer still on the JVM this moves the ColumnarToRow up one node rather than removing it, and process_batch adds a concat_batches full copy per input batch on top of a filter_record_batch per instruction. A before and after on a realistic CDC-shaped MERGE would tell us whether the operator earns the flag now, or whether the win really arrives with #5121.

6. Test gaps. WHEN NOT MATCHED BY SOURCE never executes end to end. It appears only in the equality and canonicalization test, which builds the plan but never runs the query, so not_matched_by_source_instructions is covered by Rust unit tests only. Copy-on-write WHEN MATCHED THEN DELETE is also uncovered at the SQL level, only the delta variant is tested, and multi-clause first-match-wins with conditions is likewise Rust-only. It would also be worth one test that the default (flag off) falls back cleanly and matches Spark, since that is the path every user is on today. Separately, the four withSQLConf(CometConf.COMET_ENABLED.key -> "true", ...) calls can drop the COMET_ENABLED entry, CometTestBase already enables Comet by default.

7. Iceberg MERGE coverage. The suite scaladoc points at CometIcebergWriteActionSuite for Iceberg coverage, but nothing there enables mergeRows, so with the flag on there is no Iceberg MERGE test anywhere. Given that is the workload #5122 is about, and given how the native Iceberg writer behaves differently depending on whether its child converted natively, one Iceberg MERGE test with the flag on would be worth adding.

8. Why is the output schema derived from the projections rather than output_types? merge.output_types is serialized on the wire but only read in the branch where every instruction is a Discard, which effectively never happens since RewriteMergeIntoTable appends a catch-all Keep. The live path goes through ExpandExec::build_schema. Since project already funnels every column through cast_and_stamp_schema, always building the schema from Spark's declared output_types would make the native output schema provably equal to what the JVM expects across FFI, and would delete the two-branch derivation. If the derived types are preferred in order to avoid a lossy safe cast on, say, a decimal, that reasoning is worth a comment. Otherwise the field is dead weight.

9. Comment density in merge_rows.rs. A good chunk of the 1320 lines is prose, and some of it is genuinely valuable, the UnsafeRow.setNullAt research and the cardinality-versus-instruction ordering caveat especially. But the twelve-line comment on the baseline field, the twelve lines on SEEN_HASH_TABLE_SLACK_BYTES, and several blocks that pre-argue with a hypothetical reviewer put this well above the density of the neighbouring operators, and that kind of prose rots first. A trim pass down to the non-obvious facts would help.

10. ShimCometMergeRows creates a new org.apache.comet.rules.shims package. Every sibling shim, including ShimCometWindowGroupLimit, ShimCometStreaming and ShimSubqueryBroadcast, lives in org.apache.comet.shims. Was the new package deliberate?

@andygrove andygrove added enhancement New feature or request area:Iceberg labels Sep 6, 2026
@unikdahal
unikdahal force-pushed the mergerows-operator-generic branch from e8af41e to f9a8691 Compare September 7, 2026 17:22
@unikdahal

Copy link
Copy Markdown
Contributor Author

Thanks @andygrove for the detailed review. I’ve pushed another update addressing the feedback and also updated the PR description with the latest benchmark results, compatibility boundaries, and test coverage.

A few of the review points led to more substantial changes than I initially expected, so here is a summary of what changed and the reasoning behind the remaining decisions.

  • Spark 4.1+ native MergeRows is now intentionally disabled.
    While looking into the missing per-action metrics, I found that this is more than a metrics-parity issue. Starting in Spark 4.1, the concrete MergeRowsExec contributes the action counters used to construct MergeSummary, which is then passed through the summary-aware V2 BatchWrite.commit path. Replacing MergeRowsExec without preserving that state could therefore alter the writer commit contract even if the produced rows are correct.

    For this PR, native MergeRowsExec is consequently limited to Spark 3.5.x and 4.0.x, while 4.1+ explicitly falls back to Spark's implementation. There is test coverage for that version boundary. Full 4.1+ support should preserve the metrics, action context, MergeSummary, and writer contract end-to-end rather than just exposing additional counters, so I’m keeping that out of this already-large change.

  • output_types are now authoritative for the native output schema.
    Previously the native side could derive a structurally compatible schema independently. That is risky for nested types/nullability because Spark's serialized schema is the actual execution contract. The updated implementation validates the independently derived projection schema against Spark's output_types and uses the Spark declaration for the emitted schema. This keeps the native operator aligned with Spark rather than relying on two independently inferred schemas remaining equivalent.

  • The SQL-level coverage has been expanded substantially.
    In addition to the lower-level native tests, the updated tests exercise MATCHED / NOT MATCHED / NOT MATCHED BY SOURCE behavior, matched DELETE/copy-on-write paths, conditional first-match-wins behavior, explicit native-disabled fallback, and the relevant delta/split update paths. I wanted these at the SQL level because several of the earlier bugs were not failures of an individual Rust expression—they were failures in the contract between Spark's MERGE semantics, planning, and native execution.

  • Added real Iceberg MERGE coverage with native MergeRows enabled.
    The test verifies both the resulting table contents and that execution actually engages CometMergeRowsExec rather than accidentally passing because Spark fell back to the JVM path. This gives us coverage of the integration boundary that unit-level MergeRows tests cannot provide.

  • The physical ordering difference is now documented explicitly.
    Spark's implementation happens to preserve input ordering in places where the native vectorized implementation does not guarantee it. MERGE semantics do not define that physical row order as part of the result contract, so I don’t think forcing the native operator to reproduce incidental ordering is desirable. It is, however, worth making the difference explicit so it is not mistaken for an unnoticed behavioral difference.

  • The shim was moved under the existing org.apache.comet.shims layout, and I reduced some of the implementation comments while keeping the ones that explain non-obvious Spark compatibility/cardinality invariants.

On the duplicated Spark 3.5/4.0 serde sources, I looked more closely at whether these should be moved into a shared source root, but I decided to keep the version-local copies intentionally.

The relevant compatibility window is specifically 3.5 + 4.0: Spark 3.4 does not have the corresponding MergeRowsExec, while Spark 4.1+ now deliberately follows a different path because of the MergeSummary contract described above. Comet's compatibility code is already organized around Spark-version-specific source roots, and there isn't currently a shared source-set abstraction representing exactly "3.5 and 4.0 but not 3.4 or 4.1+".

Introducing a new build/source-layout convention solely to deduplicate these small adapters would add another compatibility abstraction without eliminating the underlying version boundary. I think keeping them version-local is simpler and more consistent with the surrounding Comet structure.

The real concern with duplicated compatibility sources is silent drift, so I addressed that directly: there is now a parity check covering the 3.5/4.0 MergeRows serde/shim implementations. As long as they are intended to be equivalent, an accidental change to only one side will fail the test. If Spark eventually requires the implementations to diverge, that difference will have to be made explicit rather than happening unnoticed.

I’ve also added the benchmark results to the PR description rather than duplicating them here.

The two larger pieces I’m intentionally keeping outside this PR are:

  • native Spark 4.1+ MergeRows, because supporting it correctly requires preserving the complete MergeSummary / V2 writer contract rather than only adding metrics; and
  • Spark 4.2 InsertOnlyMergeExec, which is a separate execution operator and should be treated independently under the broader MERGE work.

At this point I think expanding #5318 further would make the change harder to review without materially improving the 3.5/4.0 implementation being added here. The current scope is therefore: make native MergeRowsExec correct and well-covered where we can preserve Spark's contract, and explicitly fall back where we currently cannot.

Thanks again for the review several of these comments helped uncover compatibility contracts that go beyond simply producing the same output rows.

@unikdahal

Copy link
Copy Markdown
Contributor Author

Hi @sunchao @andygrove, when you get a chance, could you please take another look and trigger CI for the latest changes? Thanks!

@unikdahal
unikdahal force-pushed the mergerows-operator-generic branch from 697d030 to 4e3d583 Compare September 15, 2026 16:31
@sunchao

sunchao commented Sep 15, 2026

Copy link
Copy Markdown
Member

Found one P2 issue at head a4541f6.

Valid MERGE fails for nested non-null fields

The new check in planner.rs:2206 rejects valid expressions when native metadata marks a nested field nullable.

For example, with target type ARRAY<STRUCT<n: INT NOT NULL>>:

WHEN MATCHED THEN UPDATE SET payload =
  array(named_struct('n', coalesce(s.id, 0)))

This succeeds on Spark 4.0.4, but enabling native MERGE fails with incompatible with Spark output type. Comet widens the nested nullability during expression conversion; the new guard rejects it before runtime schema normalization can handle it.

Allow safe normalization with runtime validation, or fall back during Spark planning.

Validation

  • 23 native tests passed.
  • 10 existing Spark 4.0 MERGE tests passed.
  • Two added SQL probes reproduced this same defect.
  • The four previous findings are fixed.
  • Current CI has only a passing labeling check. Spark 3.5 and Iceberg-specific execution were not tested locally.

Checkout restored; nothing posted to GitHub.

@unikdahal

Copy link
Copy Markdown
Contributor Author

@sunchao @andygrove would appreciate a review when you get a chance. Thanks!

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Summary

  • Prior state and problem: MergeRowsExec lacked native support, interrupting native MERGE execution pipelines.
  • Design approach: Add opt-in, vectorized dispatch for matched, unmatched, and target-only rows, with first-match-wins instructions and partition-scoped cardinality checking.
  • Correctness / compatibility analysis: Checked Spark 3.5.9 and 4.0.4 semantics and the 4.1+ writer-summary contract. The four earlier P2 findings and subsequent nested-nullability concern are addressed. No introduced P1/P2 issues found within this review.
  • Key design decisions: Native execution remains disabled by default and limited to Spark 3.5/4.0. Spark 4.1+ retains its concrete MergeRowsExec for MergeSummary discovery.
  • Implementation sketch: Version-specific Scala serializers connect protobuf instructions to the native stream. Existing schema-normalization and structured Spark-error machinery are reused. The instruction representation maps directly to Keep, Discard, and Split.
  • Behavioral changes worth calling out: Physical output order and failure precedence can differ as documented. Filtering and concatenation add copying. The reported 1.3× JVM-writer and 1.6× native-writer benchmark improvements were not independently reproduced.
  • Suggested improvements: No additional P1/P2 changes requested.

Reviewed all 27 files in the full PR diff against base 4479e722efebd33620f29a246f7863bd04f5df1a, using merge-base 424c31aa79d13fddf743ffa29bae3c6f146e6c5e, at head 8fc6e1f1a6c9be8185277b2c3fe3f997fa774a65. Read existing discussion and review threads. Routed skill: .ai/skills/review-comet-pr/SKILL.md. No sibling skill applied.

Exact-head CI: labeling passed. CI and CodeQL remain action_required, providing no build/test validation.

Validation: all 23 native MergeRows tests passed with --no-default-features. Three disposable native planner probes also passed, checking nested non-null assignments, rejection of actual nested nulls, and incompatible leaf types. JVM tests could not run because Maven downloads and parent-POM resolution failed with UnknownHostException: repo.maven.apache.org. Spark-version integration, Iceberg execution, and the default HDFS build were not validated locally. Disposable tests were removed and the checkout is clean.

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I merged this into current main and ran it on 3.5 and 4.0, the only profiles where the operator registers. CometMergeRowsSuite and the Iceberg MERGE tests pass on both. I also found no divergence in a wider set of cases:

  • every clause kind with NULL join keys and NULL conditions, across group, delta and split-update tables, with AQE on and off
  • cardinality violations, including one deep in a 30k-row partition
  • insert-only MERGE
  • copy-on-write and merge-on-read Iceberg tables compared against a sibling table merged by Spark

Your reply on the 7th says a parity check now guards the 3.5 and 4.0 copies of the serde, but I can't find one. CometMergeRowsNativeSuiteBase runs the same contract tests on both versions, but nothing compares the two CometMergeRows.scala files or the two ShimCometMergeRows.scala files, which are byte-identical today. Did a check get lost when the branch was squashed? If the shared suite is the check you meant, it won't catch a change to one copy that the tests don't exercise.

The branch conflicts with main again in CometIcebergWriteActionSuite.scala. Could you merge it so CI can run? The PR and merge-queue jobs only run Spark 4.1, where this operator never registers, so I'm adding run-all-spark-profiles. One thing I found isn't yours to fix. With the native writer on, a cardinality violation after the first batch reaches the user wrapped in a CometNativeException rather than as Spark's SparkRuntimeException. main does the same for any error after the first batch, so I filed #6234.

// this test will start failing and needs to flip to `assertNativeWriteEngages`.
test("native acceleration: ReplaceData (CoW MERGE) runs MergeRows and writer natively") {
assumeNativeAcceleration()
assume(isSpark35Plus && !isSpark41Plus, "native MergeRows requires Spark 3.5 through 4.0")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This assume cancels the test on Spark 4.1, which is the profile the PR and merge-queue jobs run. It also replaces the test that pinned the fallback on every profile, and deletes assertNativeWriteDoesNotEngage. On 4.1+ MergeRowsExec stays on the JVM, so that contract still holds there. Could the test branch on isSpark41Plus and keep asserting on 4.1+ that this MERGE commits once without engaging CometIcebergWriteExec? The comment at line 867 ("Unlike the unpartitioned MERGE above...") also needs updating.

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. Spark 4.1+ now keeps the JVM fallback assertion and verifies the native writer does not engage; 3.5/4.0 still assert the native path. Updated the nearby comment as well.

|WHEN NOT MATCHED THEN INSERT (id, region, amount) VALUES (s.id, s.region, s.amount)
|""".stripMargin)

var snapshot: Option[WriteSnapshot] = None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is the only Iceberg MERGE that runs with the flag on: a two-row unpartitioned copy-on-write table, checked against hand-written ids. The description lists NOT MATCHED BY SOURCE, deletes, multiple clauses, NULL conditions and cardinality as validated against Iceberg, but none of those shapes are in the suite. Could it compare against a sibling table merged with Comet off, over a partitioned table large enough to span several tasks, and add a merge-on-read variant? I ran that matrix locally on 3.5 and 4.0 and it matched, so this is about keeping it covered.

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.

Added partitioned copy-on-write and merge-on-read parity coverage against a Comet-disabled sibling table, including the clause/NULL cases. Also added cardinality-violation coverage.

val COMET_EXEC_SAMPLE_ENABLED: ConfigEntry[Boolean] =
createExecEnabledConfig("sample", defaultValue = true)
val COMET_EXEC_MERGE_ROWS_ENABLED: ConfigEntry[Boolean] =
createExecEnabledConfig("mergeRows", defaultValue = false)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

operators.md calls itself the complete reference for how Comet handles each Spark operator, but it has no row for MergeRowsExec. Could you add one, plus a CometMergeRows row in understanding-comet-plans.md? iceberg-writes.md around line 143 should also say that a copy-on-write MERGE into an unpartitioned table only reaches the native writer with spark.comet.exec.mergeRows.enabled=true. And since this flag is ignored on Spark 4.1+, could it pass notes saying so, so configs.md tells users?

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.

Added the operator/plan/Iceberg docs and the Spark 4.1+ config caveat. The notes value is included in the generated config documentation.

@unikdahal

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review, @andygrove really appreciate you taking the time to test this across versions and cases.

I've merged current main, so the conflict is resolved and have addressed your inline comments

On the source duplication: I looked at moving the shared implementation out of the 3.5/4.0 roots, but there isn't currently a common source root that maps cleanly to exactly those two versions. Introducing another Maven source root just for this felt like more build-layout complexity than this PR warrants.

So the remaining option is to keep the copies and enforce that they stay identical. I initially tried a direct diff in the CI workflow, but that felt a bit too one-off. Would you prefer a small reusable dev/ci source-parity check for this, or is an explicit CI diff sufficient?

@sunchao

sunchao commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

Found one P2 issue at head edd1c6ba8.

[P2] Skip memory-consumer registration when cardinality checking is disabled — merge_rows.rs:248.

execute() always registers a memory consumer, including insert-only MERGEs with multiple clauses where row_id_ordinal=None. That consumer never reserves memory, but the default fair_unified pool counts it when dividing the task’s budget. Enabling native MergeRows can therefore make an otherwise successful upstream join fail.

I reproduced this with the actual native operator, fair pool, and LeftAnti hash join: the join returned 4,096 rows with 96 KiB, but wrapping it in MergeRows reduced its allowance to 48 KiB and caused ResourcesExhausted. The probe used unlimited mock Spark grants, isolating the failure to native memory accounting. Register the consumer conditionally or lazily when cardinality state is needed.

Validation:

  • 23 existing native MergeRows tests passed with --no-default-features.
  • The additional regression probe confirmed the issue.
  • Earlier correctness findings appear addressed; no additional defects confirmed.
  • JVM/Iceberg execution remained blocked by Maven dependency resolution. Current CI requires maintainer action and provides no build/test result.

Checkout restored and clean. Nothing posted to GitHub.

@andygrove andygrove added the run-all-spark-profiles Run the Comet test suites against every Spark profile on this pull request, ahead of the merge queue label Sep 27, 2026

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I reran this at edd1c6ba8 on 3.5, 4.0 and 4.1, along with the Rust tests, and everything passes. The 4.1+ fallback assertion, the partitioned copy-on-write and merge-on-read parity test, and the config note all look right. A few things are still open, and most of them are inline.

On the parity question, I'd go with a small script in dev/ci/ that Preflight runs next to check-suites.py. It can hold the list of files that must stay byte-identical, with a line on why each group exists. That gives the next duplicated shim an obvious home, and it fails before any build starts. Both CometMergeRows.scala copies and both ShimCometMergeRows.scala copies are identical today, so it would pass as soon as it lands.

@sunchao's point about the memory consumer holds on current main. The fair pool gives each registered consumer pool_size / num, and execute() registers one even when row_id_ordinal is None, where nothing is ever reserved. So an insert-only MERGE shrinks the share of every other consumer in the task, which is how the join in sunchao's repro went from 96 KiB to 48 KiB. Could the stream register the consumer only when row_id_ordinal is set, with a Rust test that runs an insert-only MERGE against a pool that counts registrations?

I've added run-all-spark-profiles, since the PR and merge-queue jobs only run 4.1, where this operator never registers.

val nativeError = intercept[Exception] {
withSQLConf(
CometConf.COMET_EXEC_MERGE_ROWS_ENABLED.key -> "true",
CometConf.COMET_ICEBERG_NATIVE_WRITE_ENABLED.key -> "false") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This MERGE never reaches CometMergeRowsExec. The two-row source is broadcast, so the join stays on the JVM in the same stage as the copy-on-write target scan, and MergeRowsExec stays Spark's. I captured the failed plan with captureFailedPlans. It has Spark's MergeRowsExec and no CometMergeRowsExec, and the test still passes with spark.comet.exec.mergeRows.enabled=false. Setting spark.sql.autoBroadcastJoinThreshold=-1 here, as CometMergeRowsNativeSuiteBase does, makes it engage, and the error still surfaces as MERGE_CARDINALITY_VIOLATION. Could it set that and assert on CometMergeRowsExec in the plans from captureFailedPlans, the way the parity test does with captureWrite?

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. It now disables broadcast and asserts the failed plan actually contains CometMergeRowsExec.

// this test will start failing and needs to flip to `assertNativeWriteEngages`.
test("native acceleration: ReplaceData (CoW MERGE) honors the versioned native contract") {
assumeNativeAcceleration()
assume(isSpark35Plus, "MergeRowsExec requires Spark 3.5+")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The test this replaced also pinned the fallback on 3.4, and this assume now cancels it there. 3.4 registers nothing, so this MERGE stays on the JVM just as it does on 4.1+. I dropped the assume and took the fallback branch for isSpark41Plus || !isSpark35Plus, and it passes on 3.4. Could it do that instead of cancelling?

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. Kept the fallback path for 3.4 as well instead of skipping the test.


## MERGE INTO (MergeRowsExec)

Spark `MergeRowsExec` appears as `CometMergeRows` when native execution is enabled.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I meant the operator reference in docs/source/user-guide/latest/operators.md, the page that calls itself the complete reference for each Spark physical operator. It still has no MergeRowsExec row. Could you add one under Writes, marked ⚠️ as opt-in on 3.5 and 4.0, with a link to this section?

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.

Added the MergeRowsExec entry under Writes with the supported versions and opt-in note.


var snapshot: Option[WriteSnapshot] = None
withSQLConf(
CometConf.COMET_EXEC_MERGE_ROWS_ENABLED.key -> "true",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could this also run with spark.sql.adaptive.enabled=false? The existing AQE-off MERGE test runs with native MergeRows disabled, so nothing covers the native operator when the whole plan is converted at once. I ran this test with AQE off on 3.5 and it matches, so this is only about keeping that covered.

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.

Added. The native parity path now runs with AQE disabled too.

@unikdahal

Copy link
Copy Markdown
Contributor Author

Thanks @andygrove , addressed the remaining points and pushed the updates. Also added the parity check and fixed the unnecessary memory consumer registration.

@unikdahal
unikdahal requested a review from andygrove September 28, 2026 19:32

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Summary

  • Prior state and problem: Spark’s MergeRowsExec interrupted native MERGE pipelines because Comet had no equivalent operator.
  • Design approach: Add opt-in native dispatch for matched, unmatched, and target-only rows, with first-match-wins instructions and partition-scoped cardinality checking.
  • Correctness / compatibility analysis: Compared against Spark 3.5.9 and 4.0.4 sources and checked the Spark 4.1+ writer-summary contract. No introduced P1/P2 issues found within this review. Previously substantiated predicate, schema, subquery, and memory-accounting concerns are addressed.
  • Key design decisions: Native execution remains disabled by default and registers only on Spark 3.5/4.0. Spark 4.1+ retains its concrete MergeRowsExec for MergeSummary discovery. A parity check guards the duplicated version-specific adapters.
  • Implementation sketch: Scala serializers encode predicates and zero/one/two output projections for Discard, Keep, and Split. The native stream reuses schema normalization and structured Spark errors. Memory registration is now conditional on cardinality checking.
  • Behavioral changes worth calling out: Physical output order and failure precedence can differ as documented. Filtering and concatenation introduce copying. The reported benchmark gains were not independently reproduced.
  • Suggested improvements: No additional P1/P2 changes requested.

Reviewed all 32 changed files in the full diff from e1d2c11729c2fc60a5def4e87bb17e5b28df2a29 to b385c3a0a58a7ae6bb21f4f30dfa9d1cc26b873f, including existing reviews, issue comments, inline comments, and threads. The PR remains non-draft at that head.

Routed skills under .ai/skills/: review-comet-pr, review-comet-expression-pr, review-comet-ffi-pr, review-comet-memory-pr, and review-comet-iceberg-write-pr.

Exact-head CI: labeling passed. Comet CI and CodeQL remain action_required, providing no build/test verdict.

Validation: native build and all 25 MergeRows tests passed with --no-default-features. Three disposable planner probes passed, covering valid nested non-null assignments, rejection of actual nested nulls, and incompatible leaf types. Source-parity and suite-registration checks passed. JVM tests could not start: the wrapper and Maven 3.9.9 encountered DNS failures for repo.maven.apache.org. Spark/Iceberg integration suites and the default HDFS build were not validated locally. Disposable tests were removed and the checkout is clean. Nothing was published.

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the quick turnaround. I went through the delta since my last review. The memory consumer is now registered only when row_id_ordinal is set, and the counting-pool tests cover both cases. The parity script is wired into Preflight, and the docs, 3.4 fallback and broadcast points are all addressed. I have one test coverage question left, inline.

var snapshot: Option[WriteSnapshot] = None
withSQLConf(
CometConf.COMET_EXEC_MERGE_ROWS_ENABLED.key -> "true",
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I asked for AQE off in addition to AQE on, but this change added the flag to the existing configuration. So the partitioned, multi-file copy-on-write and merge-on-read parity matrix no longer runs with AQE on, which is the default. The AQE-on MERGE tests that remain are the two-row unpartitioned one and the cardinality one, and neither covers NOT MATCHED BY SOURCE, NULL conditions or merge-on-read. Could the per-mode body run for both values of spark.sql.adaptive.enabled, with a distinct table name per setting?

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.

Sorry @andygrove, I missed that you meant AQE off in addition to AQE on. Fixed now, the parity test runs both settings for copy-on-write and merge-on-read, with separate tables for each. The tests passed on Spark 3.5 and 4.0. I also merged the latest main and resolved the conflicts.

@unikdahal unikdahal mentioned this pull request Oct 2, 2026
2 of 8 tasks
@unikdahal

unikdahal commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor Author

@andygrove whenever you get a chance, would really appreciate another look at this. I have a few small follow-ups ready on top once this lands.

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for running the parity matrix under both AQE settings, that closes my last open question. CI still hasn't produced a result on this PR, because every run is waiting on approval. With run-all-spark-profiles on, the run for this head should cover 3.5 and 4.0 once it's approved. A few small things are left inline.

child_stream: SendableRecordBatchStream,
schema: SchemaRef,
// Partition-scoped so duplicate matches across Arrow batches are still detected.
seen: HashSet<i64>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Spark's BitmapCardinalityValidator keeps matched row ids in a Roaring64Bitmap, and this uses a HashSet<i64>. I measured both on 5M row ids. The set takes 75 MB, about 15 bytes per id. A roaring treemap takes 0.6 MB when the ids are dense, as in a co-partitioned join, and 5 to 17 MB when they are a shuffled share of 8 to 200 target partitions. Because this reservation can't spill, a MERGE with many matched rows per task can fail with ResourcesExhausted where Spark succeeds. roaring is already compiled in through iceberg-rust, and RoaringBitmap::statistics() reports capacity bytes that could size the reservation once per batch. Could you switch to RoaringTreemap here? If you'd rather keep that out of this PR, could you open an issue for it and add a line about the memory cost to the compatibility page?

"mergeRows",
defaultValue = false,
notes = Some(
"Ignored on Spark 4.1 and later, where MergeRowsExec remains on Spark so V2 writers " +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Since 4.1 is the default build, this flag does nothing there, and nothing tracks the work to change that. Could you open an issue for native MergeRowsExec on 4.1+ that covers the MergeSummary contract, and link it from this note and the compatibility page? Could the note also say the flag only takes effect on Spark 3.5 and 4.0, since 3.4 ignores it too? The comment at CometExecRule.scala:118 has the same gap.

resetTables()
captured.clear()
withSQLConf(
CometConf.COMET_ENABLED.key -> "true",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

CometTestBase already enables Comet, so the CometConf.COMET_ENABLED.key -> "true" entries here, at lines 171 and 248, and in the 4.1+ CometMergeRowsSuite at line 74 can go. This was in my first review too.

@unikdahal

Copy link
Copy Markdown
Contributor Author

Thanks @andygrove! Addressed all three comments:

  • Cardinality memory: I’d prefer to keep the RoaringTreemap switch out of this PR, since getting the reservation sizing and duplicate-vs-OOM error ordering right deserves a focused change. Opened Use RoaringTreemap for MergeRows cardinality tracking #6608 for that, and added a note about the memory cost to the compatibility page.
  • Spark 4.1+: Opened Support native MergeRowsExec on Spark 4.1+ with MergeSummary #6606 to track native MergeRowsExec support on 4.1+ with the MergeSummary contract. The config docs now clarify that the flag only takes effect on Spark 3.5 and 4.0, with links to the follow-up issue. I also updated the compatibility page and fixed the CometExecRule comment.
  • Tests: Removed the redundant COMET_ENABLED=true entries. The explicit false baselines are still retained.

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for turning these around so quickly, and for writing up #6606 and #6608 in enough detail that someone else could pick them up. The full CI run at 1680c742c passed on every Spark profile, and the logs show all of CometMergeRowsSuite and the Iceberg MERGE tests running on 3.5 and 4.0 rather than being skipped. The changes since then are docs, the config note, a comment, and test cleanup, so this looks good to me.

@andygrove
andygrove enabled auto-merge October 4, 2026 18:33
@andygrove
andygrove added this pull request to the merge queue Oct 4, 2026
Merged via the queue into apache:main with commit 207fb6f Oct 4, 2026
56 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:Iceberg enhancement New feature or request run-all-spark-profiles Run the Comet test suites against every Spark profile on this pull request, ahead of the merge queue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants