feat: add native support for MergeRowsExec (row-level MERGE INTO) - #5318
feat: add native support for MergeRowsExec (row-level MERGE INTO)#5318unikdahal wants to merge 1 commit into
Conversation
|
@peterxcli @andygrove @jordepic ready for review whenever you have time. |
|
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. |
| // `Keep(TrueLiteral, ...)` as the last instruction of the matched / not-matched-by-source | ||
| // groups. A literal condition evaluates to a `ColumnarValue::Scalar`, so handle it | ||
| // without materializing (and then AND-ing against) a same-value n-row array. | ||
| let fire = match instr.condition.evaluate(&group_batch)? { |
There was a problem hiding this comment.
[P2] Evaluate later predicates only for the remaining rows
Could you narrow to remaining before evaluating each clause condition? The mask is currently applied after instr.condition.evaluate(&group_batch), so rows handled by an earlier clause still evaluate later predicates whenever another row remains. With ANSI enabled, two matched rows having s.d = 0 and 2, and clauses WHEN MATCHED AND s.d = 0 THEN UPDATE SET v = 111 followed by WHEN MATCHED AND 2 / s.d > 0 THEN UPDATE SET v = 222, the second predicate divides by the already-handled row's zero. I ran this MERGE on Spark 4.0.4 and got [1,111], [2,222]. A two-row probe of this exact run_group fails with DivideByZero, while the same function succeeds row-at-a-time. Spark's applyInstructions returns immediately after the first matching clause, so this is a failure of an otherwise valid MERGE, not just a different error ordering.
There was a problem hiding this comment.
Fixed. run_group now physically shrinks the working batch to unclaimed rows after each instruction instead of masking a stable batch -- a claimed row is gone before the next clause's condition ever evaluates, not just masked out after. Added a regression test with your exact divide-by-zero shape.
| columns.push(expr.evaluate(batch)?.into_array(batch.num_rows())?); | ||
| } | ||
| let options = RecordBatchOptions::new().with_row_count(Some(batch.num_rows())); | ||
| RecordBatch::try_new_with_options(Arc::clone(schema), columns, &options).map_err(|e| e.into()) |
There was a problem hiding this comment.
[P2] Normalize nested output types before stamping the batch
Could this use cast_and_stamp_schema, as ExpandStream::expand does? ExpandExec::build_schema widens nested nullability across the instructions, but this direct stamp does not reconcile each projected array with that widened type. For a target column payload STRUCT<n: BOOLEAN>, UPDATE SET payload = named_struct('n', s.d IS NULL) produces a non-nullable inner n, while Spark's appended carryover Keep references the target's nullable n. The derived schema is therefore nullable, and RecordBatch::try_new_with_options rejects the update array with column types must match schema types. I reproduced that error using the exact-head CreateNamedStruct, schema builder, and run_group. The corresponding Spark 4.0.4 MERGE succeeds. Normalizing the array before stamping also succeeds and preserves the value.
There was a problem hiding this comment.
Fixed. project now uses cast_and_stamp_schema, same as ExpandStream::expand, to reconcile each projected column's nested nullability against the declared schema. Added a regression test with a non-nullable projected struct field against a nullable declared one.
sunchao
left a comment
There was a problem hiding this comment.
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.
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.
sunchao
left a comment
There was a problem hiding this comment.
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.
| case class CometMergeRowsExec( | ||
| override val nativeOp: Operator, | ||
| override val originalPlan: SparkPlan, | ||
| override val output: Seq[Attribute], | ||
| child: SparkPlan, | ||
| override val serializedPlanOpt: SerializedPlan) |
There was a problem hiding this comment.
[P2] Retain MERGE assignment subqueries
Could you preserve the instruction expressions as fields on this node, or fall back for scalar-subquery assignments? An assignment such as WHEN MATCHED THEN UPDATE SET amount = (SELECT max(amount) FROM source) is accepted by the scalar-subquery serializer, but Spark's expression traversal does not recurse into originalPlan. Consequently, CometNativeExec.prepareSubqueries and collectSubqueries cannot discover or register that subquery, leaving the native lookup without an entry (Subquery ... not found for plan ...). I verified that the corresponding Spark 4.1.3 MERGE succeeds with [1,3]; a probe compiled from this new class found one scalar subquery on the original MergeRowsExec, zero on CometMergeRowsExec, and zero returned by collectSubqueries.
| // native memory with nothing to push back on it. Accounted after the fact (rather than | ||
| // reserving the batch's row count up front and releasing the remainder) since the overshoot | ||
| // is bounded by one batch. | ||
| reservation.try_grow(new_entries * SEEN_ENTRY_BYTES)?; |
There was a problem hiding this comment.
[P2] Account for allocated hash-table capacity
The fixed 16-byte charge per ID is not conservative immediately after HashSet growth. Calling this exact check_cardinality function with 917,505 distinct IDs in 4,096-row batches succeeded against a 16 MiB GreedyMemoryPool, while an allocator probe measured 18,874,384 bytes of live table allocation versus only 14,680,080 bytes reserved. Thus an 18 MiB persistent table is accepted by a 16 MiB pool. This uncharged capacity grows with partition size, so the overshoot is not bounded by the current batch and can bypass the memory budget, risking executor OOM. Could you account for allocated table capacity, for example using DataFusion's estimate_memory_size, instead of only the number of inserted IDs?
299ad5f to
e8af41e
Compare
|
Thanks @sunchao for the detailed review. I rebased this PR onto the latest The current head includes the following fixes:
I also added/strengthened coverage for native Would appreciate another look when you have time. Thanks again for the precise reproductions, they helped tighten this substantially. |
|
Thanks for this, and thanks @sunchao for the earlier rounds. I went through the current head ( Relationship to #5122The scoping is right. #5122 covers four operators ( Two things worth naming:
@peterxcli asked to be tagged for review on the issue thread. Findings1. Rebase and get a green CI run. The branch conflicts with 2. 3. 4. Output row order diverges from Spark and is not on the compat page. 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 6. Test gaps. 7. Iceberg MERGE coverage. The suite scaladoc points at 8. Why is the output schema derived from the projections rather than 9. Comment density in 10. |
e8af41e to
f9a8691
Compare
|
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.
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 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:
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 Thanks again for the review several of these comments helped uncover compatibility contracts that go beyond simply producing the same output rows. |
Which issue does this PR close?
Part of #5122.
Rationale for this change
Row-level
MERGEplans currently fall back to Spark entirely becauseMergeRowsExechas nonative equivalent, even though the expensive parts of the plan (the source/target join, filters,
projections) are operators Comet already accelerates.
MergeRowsExecitself 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:
MergeRowsExeccan remain native while the final Icebergwrite 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?
MergeRowsExecoperator(
native/core/src/execution/operators/merge_rows.rs) reproducing Spark's dispatch semantics:MERGE_CARDINALITY_VIOLATION), mirroring Spark'sBitmapCardinalityValidator, surfaced as a realSparkRuntimeExceptionthrough the structuredSparkError/ JNI error-conversion path rather than as a generic native exception.MergeRows/MergeInstruction/MergeOutputRowprotobuf messages.CometOperatorSerdesupport forMergeRowsExec, version-gated for supported Spark versions(the operator does not exist before Spark 3.5).
spark.comet.exec.mergeRows.enabledconfiguration, disabled by default(experimental / opt-in).
output_rows/output_batches/elapsed_computemetrics.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
MERGEworkload across three execution paths:MergeRowsExec+ JVM Iceberg writerMergeRowsExec+ native Iceberg writerEnvironment
The JVM-writer comparison isolates the benefit of native
MergeRowsExec: with the finalIceberg 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
MergeRowsExecis combined with Comet's native Iceberg writer, the workload improves from1632 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.rscovering:CometMergeRowsSuite(Scala), using Spark'sInMemoryRowLevelOperationTableCatalogso the coreMergeRowsExeccontract is testedindependently of Iceberg or another connector:
SparkRuntimeException[MERGE_CARDINALITY_VIOLATION], matching Spark's exception typeand error class
End-to-end validation against Iceberg covering:
MATCHEDNOT MATCHEDNOT MATCHED BY SOURCEThe 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.