Uh oh!
There was an error while loading. Please reload this page.
[SPARK-58111][SQL] Scan and write schema narrowing for column-level UPDATE in DSv2 - #55518
[SPARK-58111][SQL] Scan and write schema narrowing for column-level UPDATE in DSv2#55518anuragmantri wants to merge 14 commits into
Conversation
fb14c34 to
ae635f4Compare| val required = | ||
| AttributeSet(dataAttrs) ++ AttributeSet(Seq(cond)) ++ AttributeSet(rowIdAttrs) | ||
| val narrowOutput = relation.output.filter(required.contains) | ||
| relation.copy(table = table, output = dedupAttrs(narrowOutput ++ rowIdAttrs ++ metadataAttrs)) |
There was a problem hiding this comment.
Can an attribute in required be missing from relation.output?rowIdAttrs seems to be added 2 times.
If we already have a dedupAttrs() then probably doesn't make sense build AttributeSets.
There was a problem hiding this comment.
Can an attribute in required be missing from relation.output?
No. dataAttrs come from the connector's requiredDataAttributes() which are resolved against relation (via V2ExpressionUtils.resolveRefs), so they're guaranteed to be present. The condition's referenced columns are also table columns from the user's WHERE clause. rowIdAttrs and metadataAttrs can be absent from relation.output (they're resolved separately), but they're not part of the filter. They're appended unconditionally afterward via dedupAttrs(narrowOutput ++ rowIdAttrs ++ metadataAttrs)
rowIdAttrs seems to be added 2 times. If we already have dedupAttrs() then probably doesn't make sense to build AttributeSets.
Agreed. I fixed it.
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Could you resolve the conflicts, @anuragmantri ?
ae635f4 to
a99bb2dCompareanuragmantri
commented
May 5, 2026
Thanks. I rebased and fixed the conflicts. |
| return new NamedReference[0]; | ||
| } | ||
There was a problem hiding this comment.
nit. Remove redundant empty line.
| * including the columns being updated. If {@link #requiredDataAttributes()} returns an empty | ||
| * array, Spark sends only the non-identity assigned columns (heuristic path). | ||
| * | ||
| * @since 4.2.0 |
| * <p> | ||
| * When empty (the default), Spark falls back to sending only the non-identity assigned columns. | ||
| * | ||
| * @since 4.2.0 |
| val table = buildOperationTable(tbl, UPDATE, CaseInsensitiveStringMap.empty()) | ||
| val updatedCols = assignments.collect { | ||
| case Assignment(key: AttributeReference, value) | ||
| if !isIdentityAssignment(key, value) => |
There was a problem hiding this comment.
One liner doesn't violate the line-length rule, does it?
-caseAssignment(key: AttributeReference, value)
-if!isIdentityAssignment(key, value) =>+caseAssignment(key: AttributeReference, value) if!isIdentityAssignment(key, value) =>| // | ||
| // When dataAttrs is non-empty, the relation output is narrowed to include only columns | ||
| // required for a column-update write. When dataAttrs is empty, the full relation.output is | ||
| // preserved. |
There was a problem hiding this comment.
For function description, please follow the community style like the other code path.
/**
* ...
*/
| // When the connector supports column updates and declares required data attributes, | ||
| // the read relation is narrowed at analysis time so that | ||
| // GroupBasedRowLevelOperationScanPlanning uses only the needed columns for the scan. | ||
| // Otherwise the full relation output is used. |
There was a problem hiding this comment.
For function description, please follow the community style like the other code path.
/**
* ...
*/
| WriteDelta(writeRelation, cond, rowDeltaPlan, relation, projections, groupFilterCond) | ||
| } | ||
| // Builds the row delta projection for the column update path. |
There was a problem hiding this comment.
For function description, please follow the community style like the other code path.
/**
* ...
*/
| dataAttrsResolved(inRowAttrs) | ||
| } | ||
| // Validates the narrow-write-schema row projection output. |
There was a problem hiding this comment.
For function description, please follow the community style like the other code path.
/**
* ...
*/
| table.skipSchemaResolution || areCompatible(inRowAttrs, outRowAttrs) | ||
| table.skipSchemaResolution || | ||
| areCompatible(inRowAttrs, outRowAttrs) || | ||
| dataAttrsResolved(inRowAttrs) |
There was a problem hiding this comment.
nit. Please minimize the change of existing code as much as possible like the following.
table.skipSchemaResolution || areCompatible(inRowAttrs, outRowAttrs) ||
dataAttrsResolved(inRowAttrs)
| * is ignored and the full table row is sent (the default behavior). | ||
| * <p> | ||
| * When non-empty, the returned columns become the write schema in declared order. | ||
| * The connector must declare all columns it wants to receive, including the columns being |
There was a problem hiding this comment.
This is very strong assumption, but it seems that this PR didn't have a protection. May I ask if we have some kind of assertion or a test coverage for this?
There was a problem hiding this comment.
Each column the connector returns passes through V2ExpressionUtils.resolveRefs which throws AnalysisException if the column is non existent.
I added a test test("column-update: requiredDataAttributes throws AnalysisException for invalid column")
| // | ||
| // ColumnPruning observes exactly these references and narrows the physical scan accordingly. | ||
| // Connectors that need additional columns in the scan (e.g., partition columns for | ||
| // distribution) should declare them in requiredDataAttributes(). |
There was a problem hiding this comment.
IIUC, for the correctness, we need to throw AnalysisException if requiredDataAttributes is invalid.
There was a problem hiding this comment.
Each column the connector returns passes through V2ExpressionUtils.resolveRefs which throws AnalysisException if the column is non existent.
I added a test test("column-update: requiredDataAttributes throws AnalysisException for invalid column")
| // Connectors that need additional columns in the scan (e.g., partition columns for | ||
| // distribution) should declare them in requiredDataAttributes(). | ||
| // | ||
| // Note: AlignUpdateAssignments guarantees all assignment keys are top-level |
There was a problem hiding this comment.
Do we have a test coverage for this, AlignUpdateAssignments contract?
There was a problem hiding this comment.
I added a new test test("column-update: nested struct field update narrows to the root struct column") that updates an inner field in a struct, the AlignUpdateAssignment returns only the root key.
| * whether pk is already in the updated columns list and, if not, add it to | ||
| * requiredDataAttributes(). | ||
| * | ||
| * @since 4.2.0 |
| // build a plan to replace read groups in the table | ||
| val writeRelation = relation.copy(table = operationTable) | ||
| val projections = buildReplaceDataProjections(query, relation.output, metadataAttrs) | ||
| val query = updatedAndRemainingRowsPlan |
There was a problem hiding this comment.
This looks like duplications: Let's use one variable instead of mixing two variables, updatedAndRemainingRowsPlan and query.
There was a problem hiding this comment.
Done, used a single variable
| // GroupBasedRowLevelOperationScanPlanning needs explicit column declarations to narrow. | ||
| val rowAttrs: Seq[Attribute] = if (isNarrow) connectorDataAttrs else relation.output | ||
| (readRelation, rowAttrs) |
There was a problem hiding this comment.
Please return metadataAttrs too to avoid the following recomputation in the caller-side.
val metadataAttrs = resolveRequiredMetadataAttrs(relation, operationTable.operation)
There was a problem hiding this comment.
I changed this to return metadataAttrs too.
| // | ||
| // Works for both the full-scan and narrow-scan CoW paths. In the narrow case, | ||
| // readRelation.output is already restricted by buildCoWReadSetup, so projecting | ||
| // all plan.output gives the correct narrow write schema. |
There was a problem hiding this comment.
Use function description style.
| * | ||
| * @since 4.2.0 | ||
| */ | ||
| default boolean supportsColumnUpdates() { |
There was a problem hiding this comment.
Given the scope of this PR, shall we mention that DELETE and MERGE ignores this method?
| * | ||
| * @since 4.2.0 | ||
| */ | ||
| default NamedReference[] requiredDataAttributes() { |
There was a problem hiding this comment.
Given the scope of this PR, shall we mention that DELETE and MERGE ignores this method?
There was a problem hiding this comment.
Even though the scope of this PR is UPDATE only, we'd like this API to work for MERGE as well (DELETE doesn't benefit since it doesn't write data columns). I'm still assessing what it takes and will add a section in the SPIP on how it could be implemented.
Happy to add a "currently only consulted for UPDATE" note in the Javadoc for now and remove it when MERGE support lands.
dongjoon-hyun
commented
May 6, 2026
I finished the first round review, @anuragmantri . |
There was a problem hiding this comment.
Thanks for the review @dongjoon-hyun. I addressed your comments and cleaned up some AI generated comments which were redundant.
| return new NamedReference[0]; | ||
| } | ||
| * including the columns being updated. If {@link #requiredDataAttributes()} returns an empty | ||
| * array, Spark sends only the non-identity assigned columns (heuristic path). | ||
| * | ||
| * @since 4.2.0 |
| * is ignored and the full table row is sent (the default behavior). | ||
| * <p> | ||
| * When non-empty, the returned columns become the write schema in declared order. | ||
| * The connector must declare all columns it wants to receive, including the columns being |
There was a problem hiding this comment.
Each column the connector returns passes through V2ExpressionUtils.resolveRefs which throws AnalysisException if the column is non existent.
I added a test test("column-update: requiredDataAttributes throws AnalysisException for invalid column")
| * whether pk is already in the updated columns list and, if not, add it to | ||
| * requiredDataAttributes(). | ||
| * | ||
| * @since 4.2.0 |
| // | ||
| // When dataAttrs is non-empty, the relation output is narrowed to include only columns | ||
| // required for a column-update write. When dataAttrs is empty, the full relation.output is | ||
| // preserved. |
| // Connectors that need additional columns in the scan (e.g., partition columns for | ||
| // distribution) should declare them in requiredDataAttributes(). | ||
| // | ||
| // Note: AlignUpdateAssignments guarantees all assignment keys are top-level |
There was a problem hiding this comment.
I added a new test test("column-update: nested struct field update narrows to the root struct column") that updates an inner field in a struct, the AlignUpdateAssignment returns only the root key.
| // | ||
| // ColumnPruning observes exactly these references and narrows the physical scan accordingly. | ||
| // Connectors that need additional columns in the scan (e.g., partition columns for | ||
| // distribution) should declare them in requiredDataAttributes(). |
There was a problem hiding this comment.
Each column the connector returns passes through V2ExpressionUtils.resolveRefs which throws AnalysisException if the column is non existent.
I added a test test("column-update: requiredDataAttributes throws AnalysisException for invalid column")
| dataAttrsResolved(inRowAttrs) | ||
| } | ||
| // Validates the narrow-write-schema row projection output. |
| table.skipSchemaResolution || areCompatible(inRowAttrs, outRowAttrs) | ||
| table.skipSchemaResolution || | ||
| areCompatible(inRowAttrs, outRowAttrs) || | ||
| dataAttrsResolved(inRowAttrs) |
| * | ||
| * @since 4.2.0 | ||
| */ | ||
| default NamedReference[] requiredDataAttributes() { |
There was a problem hiding this comment.
Even though the scope of this PR is UPDATE only, we'd like this API to work for MERGE as well (DELETE doesn't benefit since it doesn't write data columns). I'm still assessing what it takes and will add a section in the SPIP on how it could be implemented.
Happy to add a "currently only consulted for UPDATE" note in the Javadoc for now and remove it when MERGE support lands.
| .getOrElse { | ||
| throw new AnalysisException( | ||
| errorClass = "_LEGACY_ERROR_TEMP_3075", | ||
| messageParameters = Map( | ||
| "tableAttr" -> tableAttr.toString, | ||
| "scanAttrs" -> scanAttrs.mkString(","))) | ||
| } | ||
| } |
There was a problem hiding this comment.
I believe this is safe because condition-referenced columns are guaranteed to be in the scan. Please correct me if I'm wrong.
There was a problem hiding this comment.
No. Unfortunately, this PR should not remove this because the existing sanity check is used for other code path in the existing test cases. Please recover it.
I guess you may achieve your goal via the following. Please review and revise the following example for your purpose.
privatedefbuildTableToScanAttrMap(
tableAttrs: Seq[Attribute],
scanAttrs: Seq[Attribute],
requiredAttrs: AttributeSet):AttributeMap[Attribute] = {
// Table attrs may be legitimately absent from a column-update narrowed scan, so map only// those that have a matching scan attribute. Attrs referenced by the condition must always// be present (computeNarrowReadAttrs keeps them in the scan); failing to map one would// leave a dangling reference in the group filter, so keep the strict check for them.valattrMapping= tableAttrs.flatMap { tableAttr =>valmatched= scanAttrs.find(scanAttr => conf.resolver(scanAttr.name, tableAttr.name))
if (matched.isEmpty && requiredAttrs.contains(tableAttr)) {
thrownewAnalysisException(
errorClass ="_LEGACY_ERROR_TEMP_3075",
messageParameters =Map(
"tableAttr"-> tableAttr.toString,
"scanAttrs"-> scanAttrs.mkString(",")))
}
matched.map(scanAttr => tableAttr -> scanAttr)
}
AttributeMap(attrMapping)
}There was a problem hiding this comment.
Makes sense. Similar to other changes for column updates paths, I created a conditional method buildNarrowTableToScanAttrMap() which is called only during column updates and throws when any of the condition references are missing. My rationale is that the runtime filtering applies to only the filters so it is sufficient remap the filters only. Let me know if this understanding is incorrect.
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Thank you for updating, @anuragmantri .
BTW, I cannot find the vote for the mentioned SPIP. Does pass the vote officially, @anuragmantri ? For SPIP, we need an official vote result to move forward including merging something, don't we? (cc @huaxingao as the Shepherd of SPARK-56599 JIRA issue)
What changes were proposed in this pull request?
For SPIP: SPARK-56599
cc @aokolnychyi too because RowLevelOperation.java has been never changed since being added 4 years ago via the following.
anuragmantri
commented
May 8, 2026
Thanks for the review @dongjoon-hyun. For the SPIP, we are waiting for a few more maintainers to also review the design as well as the PR before going for a vote. |
e806004 to
4060cbfCompareThere was a problem hiding this comment.
I reviewed the latest head (774412c) focusing on points not covered in the earlier rounds. On top of the above Peter's comment, I added a few inline comments below.
One item with no code anchor: the PR description still says the writeUpdate defaults "delegate to write(...) so existing connectors are unaffected", but after the change from the earlier review the defaults now throw DATA_SOURCE_WRITE_UPDATE_NOT_IMPLEMENTED. Since the PR description becomes the merge commit message, please update it to match the code.
| * | ||
| * @since 4.3.0 | ||
| */ | ||
| default void writeUpdate(T record) throws IOException { |
There was a problem hiding this comment.
DATA_SOURCE_WRITE_UPDATE_NOT_IMPLEMENTED is defined and thrown here (and at :103), but no test exercises this path. Could you add a test where a connector mixes in SupportsColumnUpdates without overriding writeUpdate, and assert this error condition is raised? That keeps the new error condition covered and guards the dispatch contract.
| ], | ||
| "sqlState" : "KD009" | ||
| }, | ||
| "EMPTY_REQUIRED_DATA_ATTRIBUTES" : { |
There was a problem hiding this comment.
nit. The five new error conditions for the same SupportsColumnUpdates contract use three different prefixes (EMPTY_REQUIRED_DATA_ATTRIBUTES, REQUIRED_DATA_ATTRIBUTES_*, SPLIT_UPDATE_*). Could you consider a common prefix or a single parent condition with sub-conditions so they group together and are easier to discover?
| validatePartitionAttrsDeclared(operation, relation, connectorDataAttrs, scanOnlyDataAttrs) | ||
| } | ||
There was a problem hiding this comment.
nit. Redundant empty line.
| /** | ||
| * Variant of `buildReplaceDataUpdateProjection` for the `SupportsColumnUpdates` narrow-scan | ||
| * path. | ||
| * For narrow attributes, looks up assignments by `ExprId` via `AttributeMap`and passes through |
There was a problem hiding this comment.
nit. Missing space.
| *For narrow attributes, looks up assignments by `ExprId` via `AttributeMap`and passes through | |
| *For narrow attributes, looks up assignments by `ExprId` via `AttributeMap`and passes through |
| val connectorPassThroughValues = connectorDataAttrs.filterNot(a => | ||
| assignedKeyIds.contains(a.exprId) || rowIdAttrSet.contains(a)) | ||
| // scanOnlyDataAttrs are never assigned carry them through the write query so |
There was a problem hiding this comment.
nit. Missing punctuation here and at line 341 (write payload) they are emitted once -> write payload); they are emitted once).
| // scanOnlyDataAttrs are never assigned carry them through the write query so | |
| // scanOnlyDataAttrs are never assigned; carry them through the write query so |
| assert(actualUpdateSchema == expectedUpdateSchema, "update schema must match") | ||
| } | ||
| protected def getUpdateSummary(): org.apache.spark.sql.connector.write.UpdateSummary = { |
There was a problem hiding this comment.
nit. Please import org.apache.spark.sql.connector.write.UpdateSummary instead of using the fully-qualified name inline (here and at line 286).
| case class DataAndMetadataWritingSparkTask( | ||
| dataProj: ProjectingInternalRow, | ||
| updateDataProj: ProjectingInternalRow, |
There was a problem hiding this comment.
nit (optional). A nullable updateDataProj: ProjectingInternalRow constructor parameter (here and in DataWithProjectionWritingSparkTask) is consistent with the existing orNull usage in the delta tasks, but Option[ProjectingInternalRow] would be more idiomatic for a case class constructor. Feel free to keep as is if you prefer the symmetry.
| * | ||
| * @since 4.3.0 | ||
| */ | ||
| default void writeUpdate(T metadata, T record) throws IOException { |
There was a problem hiding this comment.
I am still thinking about the best possible name for this method. What worries me a bit is that it will also pass copied or re-inserted rows. This is different from DeltaWriter where we have these different methods. I am not sure I can offer a better name, though.
There was a problem hiding this comment.
I wonder whether even something like writeColumnUpdate would be more descriptive.
There was a problem hiding this comment.
Need to think more, no need to update.
| * Returns the columns being updated by this operation. Currently populated only for UPDATE; | ||
| * DELETE and MERGE report an empty array. | ||
| * <p> | ||
| * Nested struct field updates are reported at root-column granularity |
There was a problem hiding this comment.
Is this a temp limitation to simplify implementation?
| * | ||
| * @since 4.3.0 | ||
| */ | ||
| default Optional<StructType> updateSchema() { |
There was a problem hiding this comment.
This could technically also become columnUpdateSchema(), if we decide to do the rename. It does indicate the schema of copied rows in CoW and reinserted rows. One thing I don't like is that it is longer.
| * <p> | ||
| * Must not overlap with {@link #requiredDataAttributes()}; defaults to an empty array. | ||
| */ | ||
| default NamedReference[] scanOnlyDataAttributes() { |
There was a problem hiding this comment.
I am not sure adding this part is justified. If we need these columns for clustering / ordering as the doc says, this means they have to stay with the plan till the very end. If so, I would prefer to simply include all such data columns in requiredDataColumns and do a projection inside the connector, if needed. Iceberg has all utilities for that, for example.
At this point, I am reluctant to add the complexity to Spark for something we are not sure would be needed. It does complicate the API and also the Spark logic that is already very complicated.
We can always reconsider and add this API later.
| case other => other | ||
| } | ||
| unwrapped match { | ||
| case attr: Attribute => AttributeSet(Seq(key)).contains(attr) |
There was a problem hiding this comment.
It is a bit odd to see AttributeSet being used here? Can we do this instead?
protected def isIdentityAssignment(key: Attribute, value: Expression): Boolean = {
val valueWithoutAlias = value match {
case Alias(child, _) => child
case other => other
}
key.semanticEquals(valueWithoutAlias)
}
| case r @ ExtractV2Table(tbl: SupportsRowLevelOperations) => | ||
| checkNoGeneratedColumns(r, UPDATE) | ||
| val table = buildOperationTable(tbl, UPDATE, r.options) | ||
| val updatedCols = assignments.collect { |
There was a problem hiding this comment.
Can we add a helper like below?
private def collectUpdatedAttrs(assignments: Seq[Assignment]): Seq[AttributeReference] = {
assignments.collect {
case Assignment(key: AttributeReference, value) if !isIdentityAssignment(key, value) => key
}
}
And then pass AttributeReference to buildOperationTable?
protected def buildOperationTable(
table: SupportsRowLevelOperations,
command: Command,
options: CaseInsensitiveStringMap,
updatedAttrs: Seq[AttributeReference] = Nil): RowLevelOperationTable = {
val updatedColumns = updatedAttrs.map(attr => FieldReference(Seq(attr.name)))
...
}
So that RewriteUpdateTable becomes a bit easier. I also see you may use collectUpdatedAttrs in a few other places.
| val operation = operationTable.operation.asInstanceOf[SupportsDelta] | ||
| // resolve all needed attrs (e.g. row ID and any required metadata attrs) | ||
| // resolve all needed attrs (e.g. row ID, any required metadata attrs and optionally connector |
There was a problem hiding this comment.
It is unclear what optionally connector declared attrs means in this case. I'd opt to simplify.
// resolve all needed attrs (e.g. row ID and required data / metadata attrs)
// resolve all needed attrs (e.g. row ID or any required metadata attrs)
Or similar
| * implicitly, so a connector that needs them for partitioning resolution or write-side | ||
| * clustering must declare them in one of the two methods. | ||
| */ | ||
| private def validatePartitionAttrsDeclared( |
There was a problem hiding this comment.
Why do we need this? Like the comment says, if the connector needs, it adds them. Why should Spark be concerned about it?
There was a problem hiding this comment.
We can't be very restrictive on the Spark side and make assumptions about what the connectors may want to do.
| } else Nil | ||
| if (supportsColumnUpdate) { | ||
| validateUpdatedColumnsSubset(operation, assignments, connectorDataAttrs) |
There was a problem hiding this comment.
Why not validate validateUpdatedColumnsSubset when we resolve the attributes?
| // construct a read relation and include all required metadata columns | ||
| val readRelation = buildRelationWithAttrs(relation, operationTable, metadataAttrs, rowIdAttrs) | ||
| if (supportsColumnUpdate && operation.representUpdateAsDeleteAndInsert) { | ||
| validateNoRowIdReassignment(operation, assignments, rowIdAttrs) |
There was a problem hiding this comment.
I am not sure I understand this validation. Specifically, validateRowIdDeclared seems wrong. What if my row id is a metadata column?
| val matchedRowsPlan = Filter(cond, readRelation) | ||
| val rowDeltaPlan = if (operation.representUpdateAsDeleteAndInsert) { | ||
| buildDeletesAndInserts(matchedRowsPlan, assignments, rowIdAttrs) | ||
| val rowDeltaPlan = if (supportsColumnUpdate) { |
There was a problem hiding this comment.
I am worried about these constant if statements. We will need to find a better structure.
holdenk
commented
Sep 1, 2026
Hey @anuragmantri do you have the cycles to update this? |
anuragmantri
commented
Sep 1, 2026
Yes, I will update this. I will be actively working on this PR. @holdenk |
- Rename the five SupportsColumnUpdates error conditions under a common COLUMN_UPDATE_ prefix so they sort and are discoverable together, instead of three unrelated prefixes (EMPTY_REQUIRED_DATA_ATTRIBUTES, REQUIRED_DATA_ATTRIBUTES_*, SPLIT_UPDATE_*). - Add test coverage for DataWriter#writeUpdate's default implementation, which throws DATA_SOURCE_WRITE_UPDATE_NOT_IMPLEMENTED when a connector mixes in SupportsColumnUpdates without overriding it. - Fix a handful of doc/comment nits in RewriteUpdateTable.scala (missing space, redundant blank line, missing punctuation) and use an import for UpdateSummary instead of the fully-qualified name.
Finding 21: connector attribute declarations resolve case-insensitively but resolveRefs keeps the declared spelling, and dedupAttrs keys on exprId, so a mis-cased declaration (e.g. PK instead of pk) silently replaced the table's real attribute name in the narrow scan/write schema, breaking column pruning with INTERNAL_ERROR_ATTRIBUTE_NOT_FOUND. Map each resolved attribute back to the relation's own (by exprId) at all three resolution sites: resolveRequiredDataAttrs, resolveScanOnlyDataAttrs, and RowLevelWrite.projectedDataAttrs. Finding 22: SPLIT_UPDATE_ROW_ID_REASSIGNMENT's message advertised two remedies -- avoid reassigning row ID columns, or declare every table column in requiredDataAttributes() -- but validateNoRowIdReassignment only implemented the first. Skip the check when the declaration covers every column in the relation, since the REINSERT payload is then the full row with the new row-ID value and the DELETE half still carries the original row-ID via newLazyRowIdProjection, making reassignment safe. Written test-first: both fixtures/tests were confirmed to fail against the unmodified code before implementing each fix.
774412c to
2466fcaCompare
What changes were proposed in this pull request?
For SPIP: SPARK-56599
This PR adds an opt-in DSv2 mix-in for connectors to receive narrow rows on column-level UPDATE, so connectors can avoid reading and writing full-table rows when only a subset of columns is being updated.
Public API additions (since 4.3.0):
@Experimental) — a new mix-in on RowLevelOperation. Connectors implementrequiredDataAttributes(): NamedReference[]to declare the columns they need in the write payload, and optionallyscanOnlyDataAttributes(): NamedReference[]to declare additional columns needed only for scan/write-side planning (e.g. resolving partitioning expressions or write clustering keys) but excluded from the write payload; must not overlap with requiredDataAttributes(), defaults to an empty array.RowLevelOperationInfo.updatedColumns(): NamedReference[] (@experimental)— non-identity assignment column names (root-column granularity for nested field updates), populated by Spark before the connector's operation builder runs, so the connector can sizerequiredDataAttributes()accordingly.LogicalWriteInfo.updateSchema(): Optional<StructType> (@evolving)— narrow row schema for writeUpdate()-bound rows; schema() is empty when there are no INSERT-shaped rows (the common UPDATE-only case), and otherwise still carries the full table shape for INSERT-tagged rows.DataWriter.writeUpdate(record)andwriteUpdate(metadata, record) (@evolving)— narrow write channel; default implementations delegate to write(...) so existing connectors are unaffected.When a connector mixes in SupportsColumnUpdates, Spark's UPDATE (RewriteUpdateTable) narrows both sides of the plan:
Why are the changes needed?
Schema narrowing helps connectors request for only updated columns enabling efficient column-level updates of wide tables.
Does this PR introduce any user-facing change?
Yes, new public DSv2 connector APIs:
How was this patch tested?
New tests in:
Was this patch authored or co-authored using generative AI tooling?
I used Claude Code (Opus 5) to generate code and tests and manually reviewed the generated code.