Skip to content

[SPARK-58239][SQL] DML Command Outputs for DSv2 - #57402

Open
ZiyaZa wants to merge 3 commits into
apache:masterfrom
ZiyaZa:dsv2-command-output
Open

[SPARK-58239][SQL] DML Command Outputs for DSv2#57402
ZiyaZa wants to merge 3 commits into
apache:masterfrom
ZiyaZa:dsv2-command-output

Conversation

@ZiyaZa

@ZiyaZa ZiyaZa commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Creates 2 new APIs for DSv2 connectors to be able to provide command outputs:

  • RowLevelOperation.outputSchema() - The schema of the intended output.
  • Write.output() - The output rows.

This PR excludes support for metadata-only DELETEs and Truncate. These will come in a follow-up PR.

Why are the changes needed?

Currently, DML commands do not have any output. Some connectors need to provide users with results. For example, Delta outputs 1 row that contains various metrics about the result of the command.

Does this PR introduce any user-facing change?

Yes, DSv2 API change.

How was this patch tested?

Unit tests via the in-memory table.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: GPT 5.6 Sol

@juliuszsompolski juliuszsompolski left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, looks good and wired up nice. Just some nits / lazy questions.

Comment on lines +226 to +229
metricRow("num_affected_rows", affectedRows),
metricRow("num_updated_rows", summary.numTargetRowsUpdated()),
metricRow("num_deleted_rows", summary.numTargetRowsDeleted()),
metricRow("num_inserted_rows", summary.numTargetRowsInserted()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: I noticed that you have num_copied_rows in update and delete, but not here. Is that mirroring what e.g. Delta is doing?

@ZiyaZa ZiyaZa Jul 21, 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.

Removed num_copied_rows. Delta is not doing that, now it's consistent with what Delta is doing.

* Sink progress information collected after commit.
*/
private[sql] case class StreamWriterCommitProgress(numOutputRows: Long)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: unrelated stray but whatever

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.

I don't like these double empty lines, so decided to keep this change in 🙂

|""".stripMargin
}


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: unrelated stray but whatever

* @since 4.3.0
*/
default StructType outputSchema() {
return new StructType();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

just checking: was calling a collect() on a command df returning an empty schema next to the 0 rows before?

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.

I called .show() on a MERGE query and it showed:

++
||
++
++

So yes, the schema was empty.

@szehon-ho szehon-ho 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.

A few follow-ups on the command-output contract (leaving the throwaway RowLevelOperation build cost alone for now).

val project = Project(projectList, joinPlan)

InsertOnlyMerge(r, project)
InsertOnlyMerge(r, project, output = operationTable.operationOutput)

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.

For insert-only MERGE, the command schema comes from RowLevelOperation.outputSchema() here (operationTable.operationOutput), but the rows come from Write.output() on the Write built later via table.newWriteBuilder() in V2Writes — not from RowLevelOperation.newWriteBuilder().

A connector that only implements outputSchema() + Write.output() on the row-level Write will get a non-empty command schema but empty rows on this path. The in-memory table hides that because InMemoryWriterBuilder also overrides Write.output().

Could we document that both Write paths must implement matching output() / outputSchema(), or adjust the design so insert-only MERGE does not split schema and rows across two Writes?

* Returns the rows produced by this write after it commits successfully.
* <p>
* The returned rows must match the schema reported by
* {@link RowLevelOperation#outputSchema()}. By default, this method returns no rows.

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 javadoc ties Write.output() to RowLevelOperation#outputSchema(), but Write is shared by append / overwrite / streaming, and insert-only MERGE uses the table's regular Write (not the row-level one).

Could we spell out when Spark calls output() (after successful commit), which commands use it today, and that for insert-only MERGE the rows must match the schema from RowLevelOperation.outputSchema() even though the Write itself comes from SupportsWrite?

sql(s"UPDATE $tableNameAsString SET txt = DEFAULT WHERE pk IN (2, 8, 11)")
checkAnswer(
sql(s"UPDATE $tableNameAsString SET txt = DEFAULT WHERE pk IN (2, 8, 11)"),
Seq(Row("num_affected_rows", 1L)))

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.

Nit: command-output coverage for UPDATE/DELETE is a bit thin — these assertions are bolted onto unrelated default-value tests (same for DELETE). A dedicated test (and maybe asserting the default empty schema/rows when the connector does not override) would lock the contract more clearly.

transaction.foreach(TransactionUtils.commit)
refreshCache()
writtenRows
outputRows

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.

Open question: should Spark validate that Write.output() arity/types match the planned output / RowLevelOperation.outputSchema(), or is mismatch left as a connector footgun for this Evolving API?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants