Skip to content

Commit 7333940

Browse files
authored
feat(sql): blob v2 copy-through for MERGE/UPDATE row-level commands (#634)
1 parent bbbfb10 commit 7333940

24 files changed

Lines changed: 1182 additions & 84 deletions

File tree

docs/src/operations/dml/insert-into.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,23 @@ Blob v2 columns [read as descriptor structs](../../config.md#blob-v2-reads). Cop
209209
INSERT INTO documents_copy SELECT id, content FROM documents;
210210
```
211211

212-
`writeTo().append()` and `.overwrite()` work the same way. Joins and filters are fine if the blob column stays a direct select or alias.
213-
214-
**Limitations**
215-
216-
- Do not transform the blob column (`CASE`, `content.size`, `GROUP BY`, `UNION`, `DISTINCT` fail the write).
217-
- `MERGE`, `UPDATE`, and dynamic partition overwrite are not supported.
212+
Only direct column references (or simple aliases) from a single Lance source table are
213+
copied this way; `ORDER BY` and `LIMIT` on the SELECT are supported. `writeTo().append()`
214+
and `.overwrite()` work the same way. Transformed values (e.g. `content.size`),
215+
`DISTINCT`, `GROUP BY`, `UNION`, ordering by the blob column itself, and joins of multiple
216+
blob sources keep their descriptor semantics and cannot be inserted into a blob column. See
217+
[CREATE TABLE](../ddl/create-table.md#copying-blob-v2-columns-with-ctas) for creating a new
218+
table from a blob v2 query.
219+
220+
On Spark 3.5+, `MERGE INTO` and `UPDATE` deep-copy blob v2 columns the same way: a blob
221+
assigned from a source column (`SET t.content = s.content`) is copied from the source table,
222+
and blobs the command does not touch are carried forward from the target. Additional
223+
limitations apply:
224+
225+
- Assigning a binary literal to a blob v2 column through `UPDATE` or `MERGE` is rejected at
226+
analysis; use `INSERT` for direct binary writes.
227+
- Dynamic partition overwrite is not supported.
228+
- On Spark 3.4, `MERGE` and `UPDATE` are not supported for blob v2 tables.
218229

219230
## Writing Large String Data
220231

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package org.lance.spark;
15+
16+
import org.junit.jupiter.api.Test;
17+
18+
import static org.junit.jupiter.api.Assertions.assertThrows;
19+
import static org.junit.jupiter.api.Assertions.assertTrue;
20+
21+
public class BlobV2RowLevelUnsupportedTest extends AbstractBlobV2CopyTest {
22+
23+
@Test
24+
public void testMergeIntoBlobV2TableFailsLoudly() throws Exception {
25+
String src = "v2_rl34_merge_src_" + System.currentTimeMillis();
26+
String tgt = "v2_rl34_merge_tgt_" + System.currentTimeMillis();
27+
String fqSrc = fq(src);
28+
String fqTgt = fq(tgt);
29+
createV2BlobSource(fqSrc, row(1, deterministicBlob(141, 16)));
30+
createV2BlobSource(fqTgt, row(1, deterministicBlob(142, 16)));
31+
try {
32+
Exception ex =
33+
assertThrows(
34+
Exception.class,
35+
() ->
36+
spark.sql(
37+
"MERGE INTO "
38+
+ fqTgt
39+
+ " t USING "
40+
+ fqSrc
41+
+ " s ON t.id = s.id"
42+
+ " WHEN MATCHED THEN UPDATE SET t.data = s.data"));
43+
assertTrue(
44+
ex.getMessage() != null && !ex.getMessage().isEmpty(),
45+
"MERGE must fail with a message, got: " + ex);
46+
} finally {
47+
spark.sql("DROP TABLE IF EXISTS " + fqSrc);
48+
spark.sql("DROP TABLE IF EXISTS " + fqTgt);
49+
}
50+
}
51+
52+
@Test
53+
public void testUpdateBlobV2TableFailsLoudly() throws Exception {
54+
String tgt = "v2_rl34_update_tgt_" + System.currentTimeMillis();
55+
String fqTgt = fq(tgt);
56+
createV2BlobSource(fqTgt, row(1, deterministicBlob(143, 16)));
57+
try {
58+
Exception ex =
59+
assertThrows(
60+
Exception.class,
61+
() -> spark.sql("UPDATE " + fqTgt + " SET data = X'00' WHERE id = 1"));
62+
assertTrue(
63+
ex.getMessage() != null && !ex.getMessage().isEmpty(),
64+
"UPDATE must fail with a message, got: " + ex);
65+
} finally {
66+
spark.sql("DROP TABLE IF EXISTS " + fqTgt);
67+
}
68+
}
69+
}

lance-spark-3.5_2.12/pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
<source>src/main/java</source>
9898
<source>src/main/java11</source>
9999
<source>src/main/scala</source>
100+
<source>src/main/scala-rowlevel</source>
100101
<source>${project.build.directory}/generated-sources/antlr4</source>
101102
</sources>
102103
</configuration>

lance-spark-3.5_2.12/src/main/java/org/lance/spark/LancePositionDeltaOperation.java

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
package org.lance.spark;
1515

1616
import org.lance.spark.read.LanceScanBuilder;
17+
import org.lance.spark.utils.BlobSourceContext;
18+
import org.lance.spark.utils.BlobUtils;
1719
import org.lance.spark.write.SparkPositionDeltaWriteBuilder;
1820

1921
import org.apache.spark.sql.connector.expressions.Expressions;
@@ -23,9 +25,11 @@
2325
import org.apache.spark.sql.connector.write.LogicalWriteInfo;
2426
import org.apache.spark.sql.connector.write.RowLevelOperation;
2527
import org.apache.spark.sql.connector.write.SupportsDelta;
28+
import org.apache.spark.sql.types.StructField;
2629
import org.apache.spark.sql.types.StructType;
2730
import org.apache.spark.sql.util.CaseInsensitiveStringMap;
2831

32+
import java.util.Collections;
2933
import java.util.Map;
3034

3135
public class LancePositionDeltaOperation implements RowLevelOperation, SupportsDelta {
@@ -50,6 +54,8 @@ public class LancePositionDeltaOperation implements RowLevelOperation, SupportsD
5054

5155
private final Map<String, String> tableProperties;
5256

57+
private final Map<String, BlobSourceContext> blobSourceContexts;
58+
5359
public LancePositionDeltaOperation(
5460
Command command,
5561
StructType sparkSchema,
@@ -60,6 +66,30 @@ public LancePositionDeltaOperation(
6066
boolean managedVersioning,
6167
String fileFormatVersion,
6268
Map<String, String> tableProperties) {
69+
this(
70+
command,
71+
sparkSchema,
72+
readOptions,
73+
initialStorageOptions,
74+
namespaceImpl,
75+
namespaceProperties,
76+
managedVersioning,
77+
fileFormatVersion,
78+
tableProperties,
79+
Collections.emptyMap());
80+
}
81+
82+
private LancePositionDeltaOperation(
83+
Command command,
84+
StructType sparkSchema,
85+
LanceSparkReadOptions readOptions,
86+
Map<String, String> initialStorageOptions,
87+
String namespaceImpl,
88+
Map<String, String> namespaceProperties,
89+
boolean managedVersioning,
90+
String fileFormatVersion,
91+
Map<String, String> tableProperties,
92+
Map<String, BlobSourceContext> blobSourceContexts) {
6393
this.command = command;
6494
this.sparkSchema = sparkSchema;
6595
this.readOptions = readOptions;
@@ -69,6 +99,22 @@ public LancePositionDeltaOperation(
6999
this.managedVersioning = managedVersioning;
70100
this.fileFormatVersion = fileFormatVersion;
71101
this.tableProperties = tableProperties;
102+
this.blobSourceContexts = blobSourceContexts;
103+
}
104+
105+
public LancePositionDeltaOperation withBlobSourceContexts(
106+
Map<String, BlobSourceContext> contexts) {
107+
return new LancePositionDeltaOperation(
108+
command,
109+
sparkSchema,
110+
readOptions,
111+
initialStorageOptions,
112+
namespaceImpl,
113+
namespaceProperties,
114+
managedVersioning,
115+
fileFormatVersion,
116+
tableProperties,
117+
contexts);
72118
}
73119

74120
@Override
@@ -84,7 +130,7 @@ public ScanBuilder newScanBuilder(CaseInsensitiveStringMap caseInsensitiveString
84130

85131
@Override
86132
public DeltaWriteBuilder newWriteBuilder(LogicalWriteInfo logicalWriteInfo) {
87-
// Create write options from read options for delta operations
133+
rejectBlobV2Descriptors(logicalWriteInfo.schema());
88134
LanceSparkWriteOptions.Builder writeOptionsBuilder =
89135
LanceSparkWriteOptions.builder()
90136
.datasetUri(readOptions.getDatasetUri())
@@ -102,14 +148,44 @@ public DeltaWriteBuilder newWriteBuilder(LogicalWriteInfo logicalWriteInfo) {
102148
}
103149
}
104150
LanceSparkWriteOptions writeOptions = writeOptionsBuilder.build();
151+
Map<String, BlobSourceContext> contexts =
152+
blobSourceContexts.isEmpty()
153+
? BlobSourceContext.decodeFromWriteOptions(logicalWriteInfo.options())
154+
: blobSourceContexts;
105155
return new SparkPositionDeltaWriteBuilder(
106156
sparkSchema,
107157
writeOptions,
108158
initialStorageOptions,
109159
namespaceImpl,
110160
namespaceProperties,
111161
managedVersioning,
112-
readOptions.getTableId());
162+
readOptions.getTableId(),
163+
contexts);
164+
}
165+
166+
/**
167+
* A blob v2 column accepts only BINARY on write. If a descriptor struct reaches the delta writer
168+
* it would serialize as opaque bytes and silently corrupt the column, so any row-level plan the
169+
* copy-through rewrite could not convert to copy tokens must fail here.
170+
*/
171+
private void rejectBlobV2Descriptors(StructType writeSchema) {
172+
for (StructField tableField : sparkSchema.fields()) {
173+
if (!BlobUtils.isBlobV2SparkField(tableField)) {
174+
continue;
175+
}
176+
for (StructField writeField : writeSchema.fields()) {
177+
if (writeField.name().equals(tableField.name())
178+
&& writeField.dataType() instanceof StructType) {
179+
throw new IllegalArgumentException(
180+
String.format(
181+
"Cannot run this MERGE/UPDATE on Lance table %s: blob v2 column '%s' would "
182+
+ "receive descriptor structs instead of binary data. Assign the column "
183+
+ "directly from a blob v2 source column (deep copy) or omit it from the "
184+
+ "command",
185+
readOptions.getDatasetUri(), tableField.name()));
186+
}
187+
}
188+
}
113189
}
114190

115191
@Override
@@ -129,4 +205,8 @@ public NamedReference[] requiredMetadataAttributes() {
129205
public boolean representUpdateAsDeleteAndInsert() {
130206
return command != Command.UPDATE;
131207
}
208+
209+
Map<String, BlobSourceContext> blobSourceContexts() {
210+
return blobSourceContexts;
211+
}
132212
}

lance-spark-3.5_2.12/src/main/java/org/lance/spark/write/SparkPositionDeltaWrite.java

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import org.lance.spark.LanceSparkCatalogConfig;
2828
import org.lance.spark.LanceSparkWriteOptions;
2929
import org.lance.spark.function.LanceFragmentIdWithDefaultFunction;
30+
import org.lance.spark.utils.BlobReferenceResolver;
31+
import org.lance.spark.utils.BlobSourceContext;
3032
import org.lance.spark.utils.Utils;
3133

3234
import com.google.common.collect.ImmutableList;
@@ -94,14 +96,18 @@ public class SparkPositionDeltaWrite implements DeltaWrite, RequiresDistribution
9496
private final boolean managedVersioning;
9597
private final boolean hasStableRowIds;
9698

99+
/** Blob source contexts for resolving copy tokens, keyed by source dataset URI. */
100+
private final Map<String, BlobSourceContext> blobSourceContexts;
101+
97102
public SparkPositionDeltaWrite(
98103
StructType sparkSchema,
99104
LanceSparkWriteOptions writeOptions,
100105
Map<String, String> initialStorageOptions,
101106
String namespaceImpl,
102107
Map<String, String> namespaceProperties,
103108
boolean managedVersioning,
104-
List<String> tableId) {
109+
List<String> tableId,
110+
Map<String, BlobSourceContext> blobSourceContexts) {
105111
this.sparkSchema = sparkSchema;
106112
try (Dataset ds = Utils.openDatasetBuilder(writeOptions).build()) {
107113
this.writeOptions = writeOptions.withVersion(ds.version());
@@ -116,6 +122,7 @@ public SparkPositionDeltaWrite(
116122
this.namespaceProperties = namespaceProperties;
117123
this.tableId = tableId;
118124
this.managedVersioning = managedVersioning;
125+
this.blobSourceContexts = blobSourceContexts;
119126
}
120127

121128
@Override
@@ -151,7 +158,8 @@ public DeltaWriterFactory createBatchWriterFactory(PhysicalWriteInfo info) {
151158
namespaceImpl,
152159
namespaceProperties,
153160
tableId,
154-
hasStableRowIds);
161+
hasStableRowIds,
162+
blobSourceContexts);
155163
}
156164

157165
@Override
@@ -251,6 +259,7 @@ private static class PositionDeltaWriteFactory implements DeltaWriterFactory {
251259
private final Map<String, String> namespaceProperties;
252260
private final List<String> tableId;
253261
private final boolean hasStableRowIds;
262+
private final Map<String, BlobSourceContext> blobSourceContexts;
254263

255264
PositionDeltaWriteFactory(
256265
StructType sparkSchema,
@@ -259,34 +268,44 @@ private static class PositionDeltaWriteFactory implements DeltaWriterFactory {
259268
String namespaceImpl,
260269
Map<String, String> namespaceProperties,
261270
List<String> tableId,
262-
boolean hasStableRowIds) {
271+
boolean hasStableRowIds,
272+
Map<String, BlobSourceContext> blobSourceContexts) {
263273
this.sparkSchema = sparkSchema;
264274
this.writeOptions = writeOptions;
265275
this.initialStorageOptions = initialStorageOptions;
266276
this.namespaceImpl = namespaceImpl;
267277
this.namespaceProperties = namespaceProperties;
268278
this.tableId = tableId;
269279
this.hasStableRowIds = hasStableRowIds;
280+
this.blobSourceContexts = blobSourceContexts;
270281
}
271282

272283
@Override
273284
public DeltaWriter<InternalRow> createWriter(int partitionId, long taskId) {
274285
int batchSize = writeOptions.getBatchSize();
275286
boolean useQueuedBuffer = writeOptions.isUseQueuedWriteBuffer();
276287
boolean useLargeVarTypes = writeOptions.isUseLargeVarTypes();
288+
long maxBatchBytes = writeOptions.getMaxBatchBytes();
277289

278290
LanceSparkWriteOptions fragmentWriteOptions =
279291
writeOptions.toBuilder().enableStableRowIds(false).build();
280292
WriteParams params = fragmentWriteOptions.toWriteParams(initialStorageOptions);
281293

294+
// One resolver per write task, closed via LanceDataWriter when the task finishes. Always
295+
// created so copy tokens resolve even without captured contexts (falls back to open-by-URI).
296+
BlobReferenceResolver blobResolver = new BlobReferenceResolver(blobSourceContexts);
297+
282298
// Select buffer type based on configuration
283299
ArrowBatchWriteBuffer writeBuffer;
284300
if (useQueuedBuffer) {
285301
int queueDepth = writeOptions.getQueueDepth();
286302
writeBuffer =
287-
new QueuedArrowBatchWriteBuffer(sparkSchema, batchSize, queueDepth, useLargeVarTypes);
303+
new QueuedArrowBatchWriteBuffer(
304+
sparkSchema, batchSize, queueDepth, useLargeVarTypes, maxBatchBytes, blobResolver);
288305
} else {
289-
writeBuffer = new SemaphoreArrowBatchWriteBuffer(sparkSchema, batchSize, useLargeVarTypes);
306+
writeBuffer =
307+
new SemaphoreArrowBatchWriteBuffer(
308+
sparkSchema, batchSize, useLargeVarTypes, maxBatchBytes, blobResolver);
290309
}
291310

292311
// Create fragment in background thread
@@ -305,7 +324,8 @@ public DeltaWriter<InternalRow> createWriter(int partitionId, long taskId) {
305324

306325
return new LanceDeltaWriter(
307326
writeOptions,
308-
new LanceDataWriter(writeBuffer, fragmentCreationTask, fragmentCreationThread),
327+
new LanceDataWriter(
328+
writeBuffer, fragmentCreationTask, fragmentCreationThread, null, null, blobResolver),
309329
initialStorageOptions,
310330
hasStableRowIds);
311331
}

0 commit comments

Comments
 (0)