This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 6cda45e61b [core] Allow first-row merge engine for primary-key managed
BLOB tables. (#9201)
6cda45e61b is described below
commit 6cda45e61be66ac4e0e34deacf797519b6f37429
Author: Wenchao Wu <[email protected]>
AuthorDate: Thu Aug 13 10:08:31 2026 +0800
[core] Allow first-row merge engine for primary-key managed BLOB tables.
(#9201)
---
docs/docs/primary-key-table/blob-storage.md | 33 +++++-
.../org/apache/paimon/schema/SchemaValidation.java | 7 +-
.../operation/PrimaryKeyManagedBlobStoreTest.java | 117 ++++++++++++++++++++-
.../apache/paimon/schema/SchemaValidationTest.java | 16 +++
4 files changed, 168 insertions(+), 5 deletions(-)
diff --git a/docs/docs/primary-key-table/blob-storage.md
b/docs/docs/primary-key-table/blob-storage.md
index 871822087e..44ccd638d3 100644
--- a/docs/docs/primary-key-table/blob-storage.md
+++ b/docs/docs/primary-key-table/blob-storage.md
@@ -115,7 +115,7 @@ Primary-key managed BLOB storage has the following
requirements:
| Item | Requirement |
|------|-------------|
| Managed BLOB declaration | `BLOB`, `ARRAY<BLOB>`, and `MAP<K, BLOB>` use
`blob-field`; `blob-descriptor-field` remains inline |
-| Merge engine | `deduplicate` or `partial-update` |
+| Merge engine | `deduplicate`, `partial-update`, or `first-row` |
| Changelog producer | `none` only |
| Key usage | A managed BLOB column cannot be a primary, partition, bucket, or
sequence key |
| External data paths | `data-file.external-paths` is not supported |
@@ -191,6 +191,37 @@ collector is available.
when `blob-view.resolve.enabled` is true (default). Append upstream tables
used by `sys.blob_view(...)` must enable
`row-tracking.enabled` and `data-evolution.enabled`.
+### First-row with BLOB fields
+
+Primary-key tables may use `merge-engine=first-row` with managed `blob-field`
(`BLOB`, `ARRAY<BLOB>`, or
+`MAP<K, BLOB>`):
+
+| Mode | First-row | Changelog | Notes |
+|------|-----------|-----------|-------|
+| `blob-field` (managed scalar, array, or map) | Supported |
`changelog-producer=none` only | Keeps the first non-retract row and its
managed payload |
+
+For managed BLOB columns, set `changelog-producer=none`. `first-row` normally
also supports the `lookup` changelog
+producer, but managed BLOB storage requires `none`.
+
+`first-row` keeps the earliest surviving row for the same primary key. Later
updates to the same key therefore do not
+replace an existing managed BLOB payload. This differs from `deduplicate`,
which keeps the latest row.
+
+By default, `first-row` does not accept `DELETE` or `UPDATE_BEFORE` records.
Configure `ignore-delete=true` if your
+pipeline may emit them.
+
+```sql
+CREATE TABLE training_chunks (
+ id BIGINT,
+ name STRING,
+ chunk BYTES COMMENT '__BLOB_FIELD; raw training block',
+ PRIMARY KEY (id) NOT ENFORCED
+) WITH (
+ 'merge-engine' = 'first-row',
+ 'changelog-producer' = 'none',
+ 'blob-field' = 'chunk'
+);
+```
+
## Managed BLOB Update, Delete, and Compaction
Each incoming non-null scalar BLOB, array element, or map value is written as
a new descriptor and payload before merge
diff --git
a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
index 3bd841460f..1bd587d4ca 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
@@ -1432,9 +1432,10 @@ public class SchemaValidation {
checkArgument(
options.mergeEngine() == MergeEngine.DEDUPLICATE
- || options.mergeEngine() == MergeEngine.PARTIAL_UPDATE,
- "Primary-key managed BLOB tables only support the deduplicate
or "
- + "partial-update merge engine.");
+ || options.mergeEngine() == MergeEngine.PARTIAL_UPDATE
+ || options.mergeEngine() == MergeEngine.FIRST_ROW,
+ "Primary-key managed BLOB tables only support the deduplicate,
"
+ + "partial-update or first-row merge engine.");
checkArgument(
options.changelogProducer() == ChangelogProducer.NONE,
"Primary-key managed BLOB tables only support
changelog-producer 'none'.");
diff --git
a/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyManagedBlobStoreTest.java
b/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyManagedBlobStoreTest.java
index 01a0455e0f..bc83e80956 100644
---
a/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyManagedBlobStoreTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyManagedBlobStoreTest.java
@@ -39,6 +39,9 @@ import org.apache.paimon.io.DataFilePathFactory;
import org.apache.paimon.manifest.ManifestCommittable;
import org.apache.paimon.manifest.ManifestEntry;
import org.apache.paimon.mergetree.compact.DeduplicateMergeFunction;
+import org.apache.paimon.mergetree.compact.FirstRowMergeFunction;
+import org.apache.paimon.mergetree.compact.MergeFunctionFactory;
+import org.apache.paimon.options.Options;
import org.apache.paimon.postpone.PostponeBucketWriter;
import org.apache.paimon.schema.KeyValueFieldsExtractor;
import org.apache.paimon.schema.Schema;
@@ -353,6 +356,59 @@ class PrimaryKeyManagedBlobStoreTest {
assertThat(result.valueArray().getBlob(0).toData()).isEqualTo(expected);
}
+ @Test
+ void testFirstRowManagedBlobKeepsFirstValue() throws Exception {
+ FileIO fileIO = LocalFileIO.create();
+ TestFileStore store = createFirstRowStore(fileIO);
+ byte[] first = "first".getBytes(StandardCharsets.UTF_8);
+ byte[] second = "second".getBytes(StandardCharsets.UTF_8);
+
+ try (IOManager ioManager =
IOManager.create(tempDir.resolve("io").toString())) {
+ commitFirstRowData(
+ store,
+ ioManager,
+ Collections.singletonList(keyValue(1, RowKind.INSERT,
first)),
+ 0L);
+ ManifestEntry firstFile = store.newScan().plan().files().get(0);
+ ManagedBlobReferenceFile.Reference firstReference =
+ references(fileIO, store, firstFile).get(0);
+
+ commitFirstRowData(
+ store,
+ ioManager,
+ Collections.singletonList(keyValue(1,
RowKind.UPDATE_AFTER, second)),
+ 1L);
+ List<ManifestEntry> filesBeforeCompaction =
store.newScan().plan().files();
+ assertThat(filesBeforeCompaction).hasSize(2);
+ ManagedBlobReferenceFile.Reference secondReference = null;
+ for (ManifestEntry file : filesBeforeCompaction) {
+ for (ManagedBlobReferenceFile.Reference reference :
+ references(fileIO, store, file)) {
+ if (!reference.equals(firstReference)) {
+ secondReference = reference;
+ }
+ }
+ }
+ assertThat(secondReference).isNotNull();
+ assertThat(secondReference).isNotEqualTo(firstReference);
+
+ KeyValue read =
+
store.readKvsFromSnapshot(store.snapshotManager().latestSnapshotId()).get(0);
+ assertThat(read.value().getBlob(1).toData()).isEqualTo(first);
+
+ forceFullCompaction(store, ioManager);
+
+ List<ManifestEntry> files = store.newScan().plan().files();
+ assertThat(files).hasSize(1);
+ List<ManagedBlobReferenceFile.Reference> compactedReferences =
+ references(fileIO, store, files.get(0));
+ assertThat(compactedReferences).containsExactly(firstReference);
+ assertThat(compactedReferences).doesNotContain(secondReference);
+ read =
store.readKvsFromSnapshot(store.snapshotManager().latestSnapshotId()).get(0);
+ assertThat(read.value().getBlob(1).toData()).isEqualTo(first);
+ }
+ }
+
@Test
void testCompactionRebuildsExactBlobReferences() throws Exception {
FileIO fileIO = LocalFileIO.create();
@@ -480,6 +536,22 @@ class PrimaryKeyManagedBlobStoreTest {
return createStore(fileIO, "payloads",
DataTypes.MAP(DataTypes.STRING(), DataTypes.BLOB()));
}
+ private TestFileStore createFirstRowStore(FileIO fileIO) throws Exception {
+ return createStore(
+ fileIO,
+ "payload",
+ DataTypes.BLOB(),
+ 1,
+ options -> {
+ options.put(
+ CoreOptions.MERGE_ENGINE.key(),
+ CoreOptions.MergeEngine.FIRST_ROW.toString());
+ options.put(
+ CoreOptions.CHANGELOG_PRODUCER.key(),
+ CoreOptions.ChangelogProducer.NONE.toString());
+ });
+ }
+
private TestFileStore createStore(FileIO fileIO, String payloadName,
DataType payloadType)
throws Exception {
return createStore(fileIO, payloadName, payloadType, 1);
@@ -487,6 +559,16 @@ class PrimaryKeyManagedBlobStoreTest {
private TestFileStore createStore(
FileIO fileIO, String payloadName, DataType payloadType, int
bucket) throws Exception {
+ return createStore(fileIO, payloadName, payloadType, bucket, ignored
-> {});
+ }
+
+ private TestFileStore createStore(
+ FileIO fileIO,
+ String payloadName,
+ DataType payloadType,
+ int bucket,
+ java.util.function.Consumer<Map<String, String>> optionsCustomizer)
+ throws Exception {
Path tablePath = new Path(tempDir.toUri());
List<DataField> valueFields =
Arrays.asList(
@@ -504,6 +586,13 @@ class PrimaryKeyManagedBlobStoreTest {
options.put(CoreOptions.BUCKET.key(), String.valueOf(bucket));
options.put(CoreOptions.BLOB_FIELD.key(), payloadName);
options.put(CoreOptions.BLOB_TARGET_FILE_SIZE.key(), "1 b");
+ optionsCustomizer.accept(options);
+ MergeFunctionFactory<KeyValue> mergeFunctionFactory =
+ CoreOptions.MergeEngine.FIRST_ROW
+ .toString()
+
.equals(options.get(CoreOptions.MERGE_ENGINE.key()))
+ ?
FirstRowMergeFunction.factory(Options.fromMap(options))
+ : DeduplicateMergeFunction.factory();
TableSchema schema =
new SchemaManager(fileIO, tablePath)
.createTable(
@@ -535,7 +624,7 @@ class PrimaryKeyManagedBlobStoreTest {
keyType,
valueType,
extractor,
- DeduplicateMergeFunction.factory(),
+ mergeFunctionFactory,
schema)
.build();
}
@@ -557,9 +646,35 @@ class PrimaryKeyManagedBlobStoreTest {
return ManagedBlobReferenceFile.read(fileIO, sidecar);
}
+ private void commitFirstRowData(
+ TestFileStore store, IOManager ioManager, List<KeyValue> kvs, long
identifier)
+ throws Exception {
+ AbstractFileStoreWrite<KeyValue> write = store.newWrite();
+ try {
+ write.withIOManager(ioManager);
+ for (KeyValue kv : kvs) {
+ write.write(BinaryRow.EMPTY_ROW, 0, kv);
+ }
+ List<CommitMessage> messages = write.prepareCommit(false,
identifier);
+ try (FileStoreCommit commit = store.newCommit()) {
+ commit.commit(new ManifestCommittable(identifier, null,
messages), false);
+ }
+ } finally {
+ write.close();
+ }
+ }
+
private void forceFullCompaction(TestFileStore store) throws Exception {
+ forceFullCompaction(store, null);
+ }
+
+ private void forceFullCompaction(
+ TestFileStore store, @javax.annotation.Nullable IOManager
ioManager) throws Exception {
AbstractFileStoreWrite<KeyValue> write = store.newWrite();
try {
+ if (ioManager != null) {
+ write.withIOManager(ioManager);
+ }
write.compact(BinaryRow.EMPTY_ROW, 0, true);
List<CommitMessage> messages = write.prepareCommit(true, 1000L);
try (FileStoreCommit commit = store.newCommit()) {
diff --git
a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java
b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java
index 417a00b93b..a1801ab9c0 100644
---
a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java
@@ -631,6 +631,22 @@ class SchemaValidationTest {
assertThatCode(() ->
validateTableSchema(schema)).doesNotThrowAnyException();
}
+ @Test
+ public void testPrimaryKeyManagedBlobAllowsFirstRow() {
+ Map<String, String> options = new HashMap<>();
+ options.put(BUCKET.key(), "1");
+ options.put(CoreOptions.BLOB_FIELD.key(), "payload");
+ options.put(CoreOptions.MERGE_ENGINE.key(), "first-row");
+ options.put(CoreOptions.CHANGELOG_PRODUCER.key(), "none");
+
+ assertThatCode(
+ () ->
+ validateTableSchema(
+ primaryKeyBlobSchema(
+ options, singletonList("id"),
emptyList())))
+ .doesNotThrowAnyException();
+ }
+
@Test
public void testPrimaryKeyBlobRejectsUnsupportedSemantics() {
Map<String, String> options = new HashMap<>();