Skip to content

Spark 3.5: Add row lineage rule for DELETE to keep plans resolved - #18159

Open
rishi-rana wants to merge 1 commit into
apache:mainfrom
rishi-rana:spark-3.5-row-lineage-plan-validation
Open

rishi-rana wants to merge 1 commit into
apache:mainfrom
rishi-rana:spark-3.5-row-lineage-plan-validation

Conversation

@rishi-rana

@rishi-rana rishi-rana commented Sep 18, 2026

Copy link
Copy Markdown

Closes #18131

Problem

A copy-on-write DELETE on a v3 table fails Spark's per-rule plan validation:

[PLAN_VALIDATION_FAILED_RULE_IN_BATCH] Rule GroupBasedRowLevelOperationScanPlanning
in batch Early Filter and Projection Push-Down generated an invalid plan:
The plan becomes unresolved: 'ReplaceData RelationV2[id, data] ...
+- Filter NOT ((id = 101) <=> true)
   +- RelationV2[id, data, _file, _pos, _row_id, _last_updated_sequence_number] ...

SparkCopyOnWriteScan.rowLineageAsDataCols removes the __metadata_col marker from
_row_id and _last_updated_sequence_number so the 3.5 row-level rules do not drop them.
ReplaceData.dataInput filters out metadata attributes, so both lineage columns stay in it,
giving four columns against a target relation that supplies two, and outputResolved fails.
Only the intermediate plan is affected, which is why this surfaces just under plan validation.

Spark 4.x does not override readSchema() at all and relies on the native metadata column
semantics from SPARK-50820, matching the version split in the issue.

Fix

UPDATE and MERGE already append the lineage attributes to the target relation output in
RewriteUpdateTableForRowLineage and RewriteMergeIntoTableForRowLineage. DELETE had no
equivalent, so this adds RewriteDeleteFromTableForRowLineage alongside them. It needs no
assignments because copy-on-write DELETE carries surviving rows over unchanged.

shouldUpdatePlan now also returns false for non-Iceberg tables. Its inner match had no
default branch, and DELETE newly reaches it, so a DELETE against another v2 catalog in a
session with these extensions loaded would hit a MatchError.

I also considered dropping the rowLineageAsDataCols workaround instead. That is not viable:
with it removed the lineage columns are no longer written, and TestCopyOnWriteWithLineage
fails with Structs do not match and ArrayIndexOutOfBoundsException rather than a plan
validation error. The workaround is still required.

Testing

No new test was needed. The existing testDelete reproduces this once validation is on, so
this enables spark.sql.planChangeValidation in ExtensionsTestBase. Spark gates per-rule
validation on the spark.testing system property, but the harness sets spark.testing
as a Spark conf, so validation was never actually running.

With validation enabled and without the fix, 30 tests fail across TestCopyOnWriteDelete
and TestCopyOnWriteWithLineage. With the fix the full spark-extensions suite passes:
2501 passed, 180 skipped, 0 failures (TestRewritePositionDeleteFilesProcedure hit an
unrelated flaky REST catalog 404 during initialization in one run and passes on its own).

Notes for reviewers

  • The rule is not restricted to copy-on-write. RewriteUpdateTableForRowLineage and
    RewriteMergeIntoTableForRowLineage do not distinguish either, and I verified merge-on-read
    is unaffected (TestMergeOnReadWithLineage 40/40). Say the word if you would rather it were
    narrowed to the group-based path.
  • Unlike the sibling rules, this one falls through to the unchanged plan instead of risking a
    MatchError, and guards on resolved the way Spark's own RewriteDeleteFromTable and
    RewriteMergeIntoTableForRowLineage do.
  • Enabling plan validation for the whole extensions suite is deliberate, to stop this class of
    bug recurring, but it is the part of this change most likely to affect unrelated tests. Happy
    to scope it to the row lineage tests instead.

AI Disclosure

  • Model: Claude Opus 5
  • Platform/Tool: Claude Code
  • Human Oversight: fully reviewed
  • Prompt Summary: Investigate [Spark 3.5] V3 copy-on-write DELETE fails plan validation when spark.testing is enabled #18131, identify the root cause of the copy-on-write DELETE plan
    validation failure, and implement a fix with regression coverage. The agent reproduced the
    failure, traced it to the missing DELETE counterpart of the existing row lineage rules,
    empirically ruled out the alternative fix, and ran the full spark-extensions suite.

Copy-on-write DELETE on a v3 table left the plan unresolved after
GroupBasedRowLevelOperationScanPlanning. SparkCopyOnWriteScan drops the
metadata column marker from _row_id and _last_updated_sequence_number so the
3.5 row-level rules keep them in the output. ReplaceData.outputResolved then
counts both as data columns, while the DELETE target relation supplies only
the table columns, so the sizes never match.

UPDATE and MERGE already add the lineage attributes to the target relation
output through RewriteUpdateTableForRowLineage and
RewriteMergeIntoTableForRowLineage. DELETE had no such rule. Add
RewriteDeleteFromTableForRowLineage, which needs no assignments because
carried over rows keep the lineage values they already have.

Also return false for non-Iceberg tables in shouldUpdatePlan, which DELETE
now reaches, and enable spark.sql.planChangeValidation in the test harness.
Plan validation reads the spark.testing system property rather than the Spark
conf the harness sets, so per-rule validation never ran.

Generated-by: Claude Code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the spark label Sep 18, 2026
@manuzhang

Copy link
Copy Markdown
Member

@rishi-rana Is this fix Spark 3.5 specific? If not, open a PR against Spark 4.2 first.

@rishi-rana

rishi-rana commented Sep 18, 2026

Copy link
Copy Markdown
Author

@manuzhang Yes, this one is Spark 3.5 specific — there is no 4.2 counterpart to port it to.

The row lineage rules for row-level operations only exist under spark/v3.5:

v3.5  RewriteOperationForRowLineage.scala  RewriteUpdateTableForRowLineage.scala  RewriteMergeIntoTableForRowLineage.scala
v4.0  (none)
v4.1  (none)
v4.2  (none)

Same for the trigger. SparkCopyOnWriteScan overrides readSchema() to strip the metadata column
marker off the lineage columns only in 3.5 (added in #12736); 4.0/4.1/4.2 have no override and rely
on the native metadata column semantics from SPARK-50820. Without that marker being stripped there is
no mismatch between ReplaceData.dataInput and the target relation output, so the plan stays resolved.

I verified this: I enabled spark.sql.planChangeValidation in the 4.2
ExtensionsTestBase and ran the same three classes that fail on 3.5:

Spark 4.2 Spark 3.5 before this PR
TestCopyOnWriteDelete 112 passed, 20 skipped 26 failed
TestCopyOnWriteWithLineage 40 passed 4 failed
TestMergeOnReadWithLineage 40 passed 40 passed

192 passed / 0 failures on 4.2. I reverted that local 4.2 change since it is not part of this PR.

Happy to open a separate PR enabling spark.sql.planChangeValidation in the 4.x test harnesses if
you think it is worth having as a guard there too — it passes today, so it would be purely
preventative and seemed like a different concern from this fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Spark 3.5] V3 copy-on-write DELETE fails plan validation when spark.testing is enabled

2 participants