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 35d32cb61f [core] Support BLOB fields in partial-update tables. (#8928)
35d32cb61f is described below

commit 35d32cb61fd9812b02f8f7b023132ca64034db3c
Author: Wenchao Wu <[email protected]>
AuthorDate: Wed Aug 5 15:03:26 2026 +0800

    [core] Support BLOB fields in partial-update tables. (#8928)
---
 docs/docs/multimodal-table/blob.mdx                |   6 +-
 docs/docs/primary-key-table/blob-storage.md        |  86 +++-
 .../paimon/blob/ManagedBlobReferenceCollector.java |   2 +-
 .../compact/PartialUpdateMergeFunction.java        |  36 +-
 .../org/apache/paimon/schema/SchemaValidation.java | 106 ++++-
 .../paimon/table/PrimaryKeyFileStoreTable.java     |   5 +-
 .../paimon/table/source/AbstractDataTableRead.java |   4 +
 ...ableRead.java => BlobViewTableReadSupport.java} |  99 ++---
 .../table/source/DataEvolutionTableRead.java       | 119 ++----
 .../paimon/table/source/KeyValueTableRead.java     |  61 ++-
 .../paimon/io/PrimaryKeyBlobFileWriterTest.java    |  98 +++++
 .../compact/PartialUpdateMergeFunctionTest.java    |  59 +++
 .../apache/paimon/schema/SchemaValidationTest.java | 192 ++++++++-
 .../table/PrimaryKeyPartialUpdateBlobTest.java     | 441 +++++++++++++++++++++
 .../table/source/BlobViewTableReadSupportTest.java |  75 ++++
 .../apache/paimon/flink/PartialUpdateITCase.java   | 369 +++++++++++++++++
 16 files changed, 1567 insertions(+), 191 deletions(-)

diff --git a/docs/docs/multimodal-table/blob.mdx 
b/docs/docs/multimodal-table/blob.mdx
index 3ee89a0910..064ea567a5 100644
--- a/docs/docs/multimodal-table/blob.mdx
+++ b/docs/docs/multimodal-table/blob.mdx
@@ -79,7 +79,7 @@ Paimon supports three storage modes for BLOB fields, selected 
via **comment dire
    This mode supports `BLOB`, `ARRAY<BLOB>`, and `MAP<K, BLOB>` fields.
 
 2. **Descriptor-only storage** (`__BLOB_DESCRIPTOR_FIELD`)
-   Only serialized `BlobDescriptor` bytes are stored inline in data files. 
Paimon does not write `.blob` files for these fields, and writes must provide 
descriptor-based input.
+   Only serialized `BlobDescriptor` bytes are stored inline in data files. 
Paimon does not write `.blob` files for these fields, and non-null values must 
provide descriptor-based input.
 
 3. **Blob view storage** (`__BLOB_VIEW_FIELD`)
    Serialized `BlobViewStruct` bytes are stored inline. The struct points to a 
BLOB value in an upstream table by table identifier, BLOB field, and row id. 
The actual blob bytes are resolved from the upstream table at read time.
@@ -500,7 +500,7 @@ CREATE TABLE descriptor_table (
 );
 ```
 
-Paimon stores only serialized `BlobDescriptor` bytes in normal data files. 
Reading the blob follows the descriptor URI to access bytes, and writing 
requires descriptor input for those fields.
+Paimon stores only serialized `BlobDescriptor` bytes in normal data files. 
Reading the blob follows the descriptor URI to access bytes, and non-null 
values require descriptor input for those fields.
 
 ## Presigned URLs for OSS Blobs
 
@@ -674,7 +674,7 @@ For the Python equivalent, see [Blob Storage in 
pypaimon](../pypaimon/blob).
 
 ## Limitations
 
-1. **Primary Key Tables**: Managed BLOB storage in primary-key tables has 
additional requirements; see [Primary-Key BLOB 
Storage](../primary-key-table/blob-storage).
+1. **Primary Key Tables**: Managed BLOB storage in primary-key tables has 
additional requirements; see [Primary-Key BLOB 
Storage](../primary-key-table/blob-storage). Primary-key tables support 
`merge-engine=partial-update` with scalar `blob-descriptor-field`, managed 
`blob-field` (`changelog-producer=none`), and scalar `blob-view-field`; `ARRAY` 
/ `MAP` descriptor or view fields are not supported.
 2. **No Predicate Pushdown**: Blob columns cannot be used in filter predicates.
 3. **No Statistics**: Statistics collection is not supported for blob columns.
 4. **Append Table Options**: For append tables, `row-tracking.enabled` and 
`data-evolution.enabled` must be set to `true`.
diff --git a/docs/docs/primary-key-table/blob-storage.md 
b/docs/docs/primary-key-table/blob-storage.md
index 3eff81bb83..05acbaf3f6 100644
--- a/docs/docs/primary-key-table/blob-storage.md
+++ b/docs/docs/primary-key-table/blob-storage.md
@@ -102,9 +102,9 @@ multiple rows, and a row descriptor records its URI, 
offset, and length.
 
 :::note
 
-On append tables, `blob-descriptor-field` is descriptor-only storage and 
writes must provide a descriptor. Append-table
-`blob-field` storage still requires row tracking and data evolution. The 
managed raw-byte externalization described here
-is specific to supported primary-key tables.
+On append tables, `blob-descriptor-field` is descriptor-only storage and 
non-null values must provide a descriptor.
+Append-table `blob-field` storage still requires row tracking and data 
evolution. The managed raw-byte externalization
+described here is specific to supported primary-key tables.
 
 :::
 
@@ -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` only |
+| Merge engine | `deduplicate` or `partial-update` |
 | 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 |
@@ -123,11 +123,81 @@ Primary-key managed BLOB storage has the following 
requirements:
 
 `row-tracking.enabled` and `data-evolution.enabled` are not required for this 
primary-key mode.
 
-## Update, Delete, and Compaction
+### Partial-update with BLOB fields
 
-An update writes a new descriptor and managed payload when a scalar value, 
array element, or map value changes. A delete
-record does not write a new payload. Deduplication determines the final rows 
of each data file, and its `.blobref`
-sidecar contains only the managed packs referenced by those rows.
+Primary-key tables may use `merge-engine=partial-update` with scalar 
`blob-descriptor-field`,
+managed `blob-field` (`BLOB`, `ARRAY<BLOB>`, or `MAP<K, BLOB>`), and scalar
+`blob-view-field`:
+
+| Mode | Partial-update | Changelog | Notes |
+|------|----------------|-----------|-------|
+| Scalar `blob-descriptor-field` | Supported | Same as other PU tables | 
Inline descriptor bytes |
+| `blob-field` (managed scalar, array, or map) | Supported | 
`changelog-producer=none` only | Payload in `.managed.blob` |
+| Scalar `blob-view-field` | Supported | Same as other PU tables | Resolve on 
read via catalog |
+| `ARRAY` / `MAP` descriptor or view | Not supported | — | Scalar only |
+
+The ordering fields on the left-hand side of
+`fields.<ordering-field[,ordering-field...]>.sequence-group` cannot contain 
BLOB values. This
+includes `BLOB`, `ARRAY<BLOB>`, and `MAP<K, BLOB>` fields in any storage mode. 
BLOB fields are
+supported on the right-hand side as fields protected by the sequence group.
+
+```sql
+CREATE TABLE media_meta (
+    id BIGINT,
+    name STRING,
+    content BYTES COMMENT '__BLOB_DESCRIPTOR_FIELD; external video',
+    ts INT,
+    PRIMARY KEY (id) NOT ENFORCED
+) WITH (
+    'merge-engine' = 'partial-update',
+    'fields.ts.sequence-group' = 'name,content'
+);
+```
+
+For managed BLOB columns, set `changelog-producer=none` (same as deduplicate 
managed BLOB tables).
+Managed BLOB retract records do not retain payload values. When a managed BLOB 
field is protected
+by a sequence group and retract records are processed, it therefore cannot use 
an aggregate
+function that depends on the original retract payload. `last_value` is 
supported because it always
+clears the field on retract; other aggregate functions require
+`fields.<field-name>.ignore-retract=true`. This restriction does not apply 
when `ignore-delete=true`
+or when the managed BLOB field is not protected by a sequence group.
+
+```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' = 'partial-update',
+    'changelog-producer' = 'none',
+    'blob-field' = 'chunk'
+);
+```
+
+Non-null values update the corresponding column; null values do not update the 
field (standard partial-update
+semantics). Within a sequence group, a null sequence value skips the entire 
group. Without aggregate functions, an
+incoming sequence value that is newer or equal replaces every field in the 
group, including replacing a BLOB value
+with null; an older record is ignored. With aggregate functions, every record 
with a non-null sequence value
+participates in aggregation or retraction, even when its sequence value is 
older. For example, `last_value` clears
+the field for both newer and older retract records.
+
+Managed BLOB partial updates externalize each non-null scalar BLOB, array 
element, or map value into a
+`.managed.blob` pack. Empty collections and collections containing only null 
values write no payload. BLOB garbage
+collection for orphaned packs is not implemented yet; repeated updates can 
leave unreachable storage until a future
+collector is available.
+
+`blob-view-field` columns store serialized view structs inline. Reads resolve 
upstream blob bytes through the catalog
+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`.
+
+## 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
+rules are applied. An update later discarded by partial-update or sequence 
rules can therefore leave an orphaned
+payload pack. A delete record does not write a new payload. The merge engine 
determines the logical final row during
+reads and compaction. Each data file's `.blobref` sidecar records managed 
packs referenced by non-retract key-values in
+that file, which may include intermediate partial-update payloads before 
compaction.
 
 Compaction preserves descriptors for surviving values and creates new 
`.blobref` sidecars from the compacted output.
 It does not copy the referenced payload bytes into new `.managed.blob` packs. 
This keeps ordinary compaction cost
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/blob/ManagedBlobReferenceCollector.java
 
b/paimon-core/src/main/java/org/apache/paimon/blob/ManagedBlobReferenceCollector.java
index 529b077888..841d3d1eed 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/blob/ManagedBlobReferenceCollector.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/blob/ManagedBlobReferenceCollector.java
@@ -37,7 +37,7 @@ import java.util.Set;
 import static org.apache.paimon.types.BlobType.isBlobFileField;
 import static org.apache.paimon.utils.Preconditions.checkState;
 
-/** Collects exact managed BLOB dependencies from the final rows of one data 
file. */
+/** Collects managed BLOB dependencies from non-retract key-values written to 
one data file. */
 public class ManagedBlobReferenceCollector {
 
     private final FileIO fileIO;
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunction.java
 
b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunction.java
index 6cc551baec..00bc83b152 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunction.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunction.java
@@ -69,6 +69,30 @@ public class PartialUpdateMergeFunction implements 
MergeFunction<KeyValue> {
             "The sequence-group '%s' contains primary key field '%s', "
                     + "which is not allowed. Primary key columns cannot be put 
in sequence-group.";
 
+    public static boolean isSequenceGroupOption(String optionKey) {
+        return optionKey.startsWith(FIELDS_PREFIX + ".")
+                && optionKey.endsWith("." + SEQUENCE_GROUP);
+    }
+
+    public static boolean isSequenceGroupOptionCandidate(String optionKey) {
+        return optionKey.startsWith(FIELDS_PREFIX) && 
optionKey.endsWith(SEQUENCE_GROUP);
+    }
+
+    public static List<String> sequenceGroupOrderingFields(String optionKey) {
+        checkArgument(
+                isSequenceGroupOption(optionKey), "Invalid sequence-group 
option: %s", optionKey);
+        return Arrays.asList(
+                optionKey
+                        .substring(
+                                FIELDS_PREFIX.length() + 1,
+                                optionKey.length() - SEQUENCE_GROUP.length() - 
1)
+                        .split(FIELDS_SEPARATOR));
+    }
+
+    public static List<String> sequenceGroupProtectedFields(String 
optionValue) {
+        return Arrays.asList(optionValue.split(FIELDS_SEPARATOR));
+    }
+
     private final InternalRow.FieldGetter[] getters;
     private final boolean ignoreDelete;
     private final List<WrapperWithFieldIndex<FieldsComparator>> 
fieldSeqComparators;
@@ -413,21 +437,15 @@ public class PartialUpdateMergeFunction implements 
MergeFunction<KeyValue> {
             for (Map.Entry<String, String> entry : options.toMap().entrySet()) 
{
                 String k = entry.getKey();
                 String v = entry.getValue();
-                if (k.startsWith(FIELDS_PREFIX) && k.endsWith(SEQUENCE_GROUP)) 
{
+                if (isSequenceGroupOptionCandidate(k)) {
                     int[] sequenceFields =
-                            Arrays.stream(
-                                            k.substring(
-                                                            
FIELDS_PREFIX.length() + 1,
-                                                            k.length()
-                                                                    - 
SEQUENCE_GROUP.length()
-                                                                    - 1)
-                                                    .split(FIELDS_SEPARATOR))
+                            sequenceGroupOrderingFields(k).stream()
                                     .mapToInt(fieldName -> 
requireField(fieldName, fieldNames))
                                     .toArray();
 
                     Supplier<FieldsComparator> userDefinedSeqComparator =
                             () -> UserDefinedSeqComparator.create(rowType, 
sequenceFields, true);
-                    Arrays.stream(v.split(FIELDS_SEPARATOR))
+                    sequenceGroupProtectedFields(v).stream()
                             .map(fieldName -> requireField(fieldName, 
fieldNames))
                             .forEach(
                                     field -> {
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 7b9dead77c..0e57e364b5 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
@@ -34,6 +34,7 @@ import 
org.apache.paimon.globalindex.bitmap.BitmapGlobalIndexerFactory;
 import org.apache.paimon.globalindex.btree.BTreeGlobalIndexerFactory;
 import org.apache.paimon.mergetree.compact.aggregate.FieldAggregator;
 import 
org.apache.paimon.mergetree.compact.aggregate.factory.FieldAggregatorFactory;
+import 
org.apache.paimon.mergetree.compact.aggregate.factory.FieldLastValueAggFactory;
 import org.apache.paimon.options.ConfigOption;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.table.BucketMode;
@@ -90,6 +91,10 @@ import static 
org.apache.paimon.CoreOptions.SNAPSHOT_NUM_RETAINED_MAX;
 import static org.apache.paimon.CoreOptions.SNAPSHOT_NUM_RETAINED_MIN;
 import static org.apache.paimon.CoreOptions.STREAMING_READ_OVERWRITE;
 import static org.apache.paimon.format.FileFormat.vectorFileFormat;
+import static 
org.apache.paimon.mergetree.compact.PartialUpdateMergeFunction.isSequenceGroupOption;
+import static 
org.apache.paimon.mergetree.compact.PartialUpdateMergeFunction.isSequenceGroupOptionCandidate;
+import static 
org.apache.paimon.mergetree.compact.PartialUpdateMergeFunction.sequenceGroupOrderingFields;
+import static 
org.apache.paimon.mergetree.compact.PartialUpdateMergeFunction.sequenceGroupProtectedFields;
 import static org.apache.paimon.schema.TableSchema.PAIMON_07_VERSION;
 import static 
org.apache.paimon.table.PrimaryKeyTableUtils.createMergeFunctionFactory;
 import static org.apache.paimon.table.SpecialFields.KEY_FIELD_PREFIX;
@@ -156,6 +161,8 @@ public class SchemaValidation {
 
         validateSequenceField(schema, options);
 
+        validateSequenceGroupOrderingFields(schema, options);
+
         validateMergeFunction(schema);
 
         ChangelogProducer changelogProducer = options.changelogProducer();
@@ -220,6 +227,7 @@ public class SchemaValidation {
         Set<String> blobDescriptorFields = 
validateBlobDescriptorFields(tableRowType, options);
         Set<String> blobViewFields =
                 validateBlobViewFields(tableRowType, options, 
blobDescriptorFields);
+        validatePrimaryKeyBlobKeyConfiguration(schema, options);
         validatePrimaryKeyBlobConfiguration(schema, options);
         Set<String> blobInlineFields = new HashSet<>(blobDescriptorFields);
         blobInlineFields.addAll(blobViewFields);
@@ -1423,6 +1431,89 @@ public class SchemaValidation {
             return;
         }
 
+        checkArgument(
+                options.mergeEngine() == MergeEngine.DEDUPLICATE
+                        || options.mergeEngine() == MergeEngine.PARTIAL_UPDATE,
+                "Primary-key managed BLOB tables only support the deduplicate 
or "
+                        + "partial-update merge engine.");
+        checkArgument(
+                options.changelogProducer() == ChangelogProducer.NONE,
+                "Primary-key managed BLOB tables only support 
changelog-producer 'none'.");
+        checkArgument(
+                options.dataFileExternalPaths() == null,
+                "Primary-key managed BLOB tables do not support '%s'.",
+                CoreOptions.DATA_FILE_EXTERNAL_PATHS.key());
+        checkArgument(
+                !options.pkClusteringOverride(),
+                "Primary-key managed BLOB tables do not support '%s'.",
+                CoreOptions.PK_CLUSTERING_OVERRIDE.key());
+
+        if (options.mergeEngine() == MergeEngine.PARTIAL_UPDATE && 
!options.ignoreDelete()) {
+            Set<String> fieldsProtectedBySequenceGroup =
+                    options.toMap().entrySet().stream()
+                            .filter(entry -> 
isSequenceGroupOption(entry.getKey()))
+                            .flatMap(
+                                    entry ->
+                                            
sequenceGroupProtectedFields(entry.getValue()).stream())
+                            .collect(Collectors.toSet());
+            for (String field : managedBlobFields) {
+                if (!fieldsProtectedBySequenceGroup.contains(field)) {
+                    continue;
+                }
+                String aggregateFunction = options.fieldAggFunc(field);
+                if (aggregateFunction == null) {
+                    aggregateFunction = options.fieldsDefaultFunc();
+                }
+                checkArgument(
+                        aggregateFunction == null
+                                || 
FieldLastValueAggFactory.NAME.equals(aggregateFunction)
+                                || options.fieldAggIgnoreRetract(field),
+                        "Managed BLOB field '%s' cannot use aggregate function 
'%s' because "
+                                + "managed BLOB payloads are not retained in 
retract messages. "
+                                + "Set 'fields.%s.ignore-retract' to true to 
ignore retract messages.",
+                        field,
+                        aggregateFunction,
+                        field);
+            }
+        }
+    }
+
+    private static void validateSequenceGroupOrderingFields(
+            TableSchema schema, CoreOptions options) {
+        if (options.mergeEngine() != MergeEngine.PARTIAL_UPDATE) {
+            return;
+        }
+
+        RowType rowType = new RowType(schema.fields());
+        for (String optionKey : options.toMap().keySet()) {
+            if (!isSequenceGroupOptionCandidate(optionKey)) {
+                continue;
+            }
+            for (String fieldName : sequenceGroupOrderingFields(optionKey)) {
+                DataField field = rowType.getField(fieldName);
+                checkArgument(
+                        !containsType(field.type(), type -> 
type.is(DataTypeRoot.BLOB)),
+                        "Field '%s' with type %s cannot be used as a 
sequence-group ordering "
+                                + "field in option '%s'.",
+                        fieldName,
+                        field.type(),
+                        optionKey);
+            }
+        }
+    }
+
+    private static void validatePrimaryKeyBlobKeyConfiguration(
+            TableSchema schema, CoreOptions options) {
+        if (schema.primaryKeys().isEmpty()) {
+            return;
+        }
+
+        Set<String> managedBlobFields =
+                fieldNamesInBlobFile(new RowType(schema.fields()), 
options.blobInlineField());
+        if (managedBlobFields.isEmpty()) {
+            return;
+        }
+
         List<String> primaryKeyBlobFields =
                 managedBlobFields.stream()
                         .filter(schema.primaryKeys()::contains)
@@ -1449,21 +1540,6 @@ public class SchemaValidation {
                 sequenceBlobFields.isEmpty(),
                 "Managed BLOB fields cannot be sequence fields: %s.",
                 sequenceBlobFields);
-
-        checkArgument(
-                options.mergeEngine() == MergeEngine.DEDUPLICATE,
-                "Primary-key managed BLOB tables only support the deduplicate 
merge engine.");
-        checkArgument(
-                options.changelogProducer() == ChangelogProducer.NONE,
-                "Primary-key managed BLOB tables only support 
changelog-producer 'none'.");
-        checkArgument(
-                options.dataFileExternalPaths() == null,
-                "Primary-key managed BLOB tables do not support '%s'.",
-                CoreOptions.DATA_FILE_EXTERNAL_PATHS.key());
-        checkArgument(
-                !options.pkClusteringOverride(),
-                "Primary-key managed BLOB tables do not support '%s'.",
-                CoreOptions.PK_CLUSTERING_OVERRIDE.key());
     }
 
     private static void validateIncrementalClustering(TableSchema schema, 
CoreOptions options) {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/PrimaryKeyFileStoreTable.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/PrimaryKeyFileStoreTable.java
index 5e48557d76..ca482ca095 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/PrimaryKeyFileStoreTable.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/PrimaryKeyFileStoreTable.java
@@ -150,7 +150,10 @@ public class PrimaryKeyFileStoreTable extends 
AbstractFileStoreTable {
     @Override
     public InnerTableRead newRead() {
         return new KeyValueTableRead(
-                () -> store().newRead(), () -> store().newBatchRawFileRead(), 
schema());
+                () -> store().newRead(),
+                () -> store().newBatchRawFileRead(),
+                schema(),
+                catalogEnvironment.dependencyReadContext());
     }
 
     @Override
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataTableRead.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataTableRead.java
index f349047fe8..c46e1f299f 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataTableRead.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataTableRead.java
@@ -103,6 +103,10 @@ public abstract class AbstractDataTableRead implements 
InnerTableRead {
         return predicate;
     }
 
+    protected boolean shouldExecuteFilter() {
+        return executeFilter;
+    }
+
     @Override
     public RecordReader<InternalRow> createReader(Split split) throws 
IOException {
         QueryAuthContext queryAuthContext = unwrapQueryAuthSplit(split);
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionTableRead.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/BlobViewTableReadSupport.java
similarity index 60%
copy from 
paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionTableRead.java
copy to 
paimon-core/src/main/java/org/apache/paimon/table/source/BlobViewTableReadSupport.java
index bf41ff70b6..6602d40a6e 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionTableRead.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/BlobViewTableReadSupport.java
@@ -27,10 +27,8 @@ import org.apache.paimon.data.BlobViewResolver;
 import org.apache.paimon.data.BlobViewStruct;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.TopN;
 import org.apache.paimon.reader.RecordReader;
-import org.apache.paimon.schema.TableSchema;
-import org.apache.paimon.table.source.splitread.SplitReadConfig;
-import org.apache.paimon.table.source.splitread.SplitReadProvider;
 import org.apache.paimon.types.DataTypeRoot;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.BlobViewLookup;
@@ -41,46 +39,15 @@ import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.LinkedHashSet;
-import java.util.List;
 import java.util.Set;
-import java.util.function.Function;
 import java.util.function.Supplier;
 
-/** A {@link TableRead} for data-evolution enabled append-only tables. */
-public class DataEvolutionTableRead extends AppendTableRead {
+/** Shared helpers for resolving {@link 
org.apache.paimon.CoreOptions#BLOB_VIEW_FIELD} on read. */
+final class BlobViewTableReadSupport {
 
-    @Nullable private final CatalogContext catalogContext;
-    @Nullable private final Supplier<InnerTableRead> readFactory;
+    private BlobViewTableReadSupport() {}
 
-    public DataEvolutionTableRead(
-            List<Function<SplitReadConfig, SplitReadProvider>> 
providerFactories,
-            TableSchema schema,
-            @Nullable CatalogContext catalogContext,
-            @Nullable Supplier<InnerTableRead> readFactory) {
-        super(providerFactories, schema);
-        this.catalogContext = catalogContext;
-        this.readFactory = readFactory;
-    }
-
-    @Override
-    public RecordReader<InternalRow> createReader(Split split) throws 
IOException {
-        QueryAuthContext queryAuthContext = unwrapQueryAuthSplit(split);
-        if (catalogContext != null) {
-            int[] blobViewFields = blobViewFields(currentReadType());
-            if (blobViewFields.length > 0) {
-                if (readFactory == null) {
-                    throw new IllegalStateException(
-                            "Cannot read blob-view-field fields without a 
readFactory.");
-                }
-                return createBlobViewReader(
-                        queryAuthContext.split(), 
queryAuthContext.authResult(), blobViewFields);
-            }
-        }
-        return createDataReader(queryAuthContext.split(), 
queryAuthContext.authResult());
-    }
-
-    private int[] blobViewFields(RowType rowType) {
-        CoreOptions options = CoreOptions.fromMap(schema().options());
+    static int[] blobViewFieldIndexes(RowType rowType, CoreOptions options) {
         if (!options.blobViewResolveEnabled()) {
             return new int[0];
         }
@@ -99,28 +66,51 @@ public class DataEvolutionTableRead extends AppendTableRead 
{
                 .toArray();
     }
 
-    private RecordReader<InternalRow> createBlobViewReader(
-            Split split, @Nullable TableQueryAuthResult authResult, int[] 
blobViewFields)
+    static RecordReader<InternalRow> createBlobViewReader(
+            CatalogContext catalogContext,
+            Split split,
+            @Nullable TableQueryAuthResult authResult,
+            int[] blobViewFields,
+            RowType readType,
+            @Nullable Predicate predicate,
+            @Nullable TopN topN,
+            @Nullable Integer limit,
+            boolean executeFilter,
+            RecordReaderSupplier dataReaderSupplier,
+            Supplier<InnerTableRead> prescanReadSupplier)
             throws IOException {
-        RowType blobViewOnlyType = currentReadType().project(blobViewFields);
-        InnerTableRead prescanRead = readFactory.get();
-        prescanRead.withReadType(blobViewOnlyType);
-        Predicate predicate = predicate();
+        RowType prescanType = executeFilter ? readType : 
readType.project(blobViewFields);
+        int[] prescanBlobViewFields = new int[blobViewFields.length];
+        if (executeFilter) {
+            System.arraycopy(blobViewFields, 0, prescanBlobViewFields, 0, 
blobViewFields.length);
+        } else {
+            for (int i = 0; i < prescanBlobViewFields.length; i++) {
+                prescanBlobViewFields[i] = i;
+            }
+        }
+        InnerTableRead prescanRead = prescanReadSupplier.get();
+        prescanRead.withReadType(prescanType);
         if (predicate != null) {
             prescanRead.withFilter(predicate);
         }
-        configureBlobViewPrescanRead(prescanRead);
+        if (topN != null) {
+            prescanRead.withTopN(topN);
+        }
+        if (limit != null) {
+            prescanRead.withLimit(limit);
+        }
+
         Split prescanSplit = authResult != null ? new QueryAuthSplit(split, 
authResult) : split;
         LinkedHashSet<BlobViewStruct> viewStructs = new LinkedHashSet<>();
         RecordReader<InternalRow> prescanReader = 
prescanRead.createReader(prescanSplit);
         try {
             prescanReader.forEachRemaining(
                     row -> {
-                        for (int i = 0; i < blobViewFields.length; i++) {
-                            if (row.isNullAt(i)) {
+                        for (int field : prescanBlobViewFields) {
+                            if (row.isNullAt(field)) {
                                 continue;
                             }
-                            Blob blob = row.getBlob(i);
+                            Blob blob = row.getBlob(field);
                             if (!(blob instanceof BlobView)) {
                                 throw new IllegalArgumentException(
                                         "blob-view-field requires blob field 
value to be a "
@@ -136,7 +126,7 @@ public class DataEvolutionTableRead extends AppendTableRead 
{
         BlobViewResolver resolver =
                 BlobViewLookup.createResolver(catalogContext, new 
ArrayList<>(viewStructs));
 
-        RecordReader<InternalRow> reader = createDataReader(split, authResult);
+        RecordReader<InternalRow> reader = dataReaderSupplier.get();
         Set<Integer> blobViewFieldSet = new HashSet<>();
         for (int field : blobViewFields) {
             blobViewFieldSet.add(field);
@@ -144,12 +134,9 @@ public class DataEvolutionTableRead extends 
AppendTableRead {
         return reader.transform(row -> new BlobViewResolvingRow(row, 
blobViewFieldSet, resolver));
     }
 
-    private void configureBlobViewPrescanRead(InnerTableRead prescanRead) {
-        if (topN != null) {
-            prescanRead.withTopN(topN);
-        }
-        if (limit != null) {
-            prescanRead.withLimit(limit);
-        }
+    @FunctionalInterface
+    interface RecordReaderSupplier {
+
+        RecordReader<InternalRow> get() throws IOException;
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionTableRead.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionTableRead.java
index bf41ff70b6..60bd2b99fe 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionTableRead.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionTableRead.java
@@ -20,29 +20,16 @@ package org.apache.paimon.table.source;
 
 import org.apache.paimon.CoreOptions;
 import org.apache.paimon.catalog.CatalogContext;
-import org.apache.paimon.catalog.TableQueryAuthResult;
-import org.apache.paimon.data.Blob;
-import org.apache.paimon.data.BlobView;
-import org.apache.paimon.data.BlobViewResolver;
-import org.apache.paimon.data.BlobViewStruct;
 import org.apache.paimon.data.InternalRow;
-import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.reader.RecordReader;
 import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.table.source.splitread.SplitReadConfig;
 import org.apache.paimon.table.source.splitread.SplitReadProvider;
-import org.apache.paimon.types.DataTypeRoot;
-import org.apache.paimon.types.RowType;
-import org.apache.paimon.utils.BlobViewLookup;
 
 import javax.annotation.Nullable;
 
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.LinkedHashSet;
 import java.util.List;
-import java.util.Set;
 import java.util.function.Function;
 import java.util.function.Supplier;
 
@@ -65,91 +52,33 @@ public class DataEvolutionTableRead extends AppendTableRead 
{
     @Override
     public RecordReader<InternalRow> createReader(Split split) throws 
IOException {
         QueryAuthContext queryAuthContext = unwrapQueryAuthSplit(split);
-        if (catalogContext != null) {
-            int[] blobViewFields = blobViewFields(currentReadType());
-            if (blobViewFields.length > 0) {
-                if (readFactory == null) {
-                    throw new IllegalStateException(
-                            "Cannot read blob-view-field fields without a 
readFactory.");
-                }
-                return createBlobViewReader(
-                        queryAuthContext.split(), 
queryAuthContext.authResult(), blobViewFields);
-            }
-        }
-        return createDataReader(queryAuthContext.split(), 
queryAuthContext.authResult());
-    }
-
-    private int[] blobViewFields(RowType rowType) {
         CoreOptions options = CoreOptions.fromMap(schema().options());
-        if (!options.blobViewResolveEnabled()) {
-            return new int[0];
-        }
-
-        Set<String> blobViewFieldNames = options.blobViewField();
-        if (blobViewFieldNames.isEmpty()) {
-            return new int[0];
-        }
-
-        return rowType.getFields().stream()
-                .filter(
-                        field ->
-                                field.type().is(DataTypeRoot.BLOB)
-                                        && 
blobViewFieldNames.contains(field.name()))
-                .mapToInt(field -> rowType.getFieldIndex(field.name()))
-                .toArray();
-    }
-
-    private RecordReader<InternalRow> createBlobViewReader(
-            Split split, @Nullable TableQueryAuthResult authResult, int[] 
blobViewFields)
-            throws IOException {
-        RowType blobViewOnlyType = currentReadType().project(blobViewFields);
-        InnerTableRead prescanRead = readFactory.get();
-        prescanRead.withReadType(blobViewOnlyType);
-        Predicate predicate = predicate();
-        if (predicate != null) {
-            prescanRead.withFilter(predicate);
-        }
-        configureBlobViewPrescanRead(prescanRead);
-        Split prescanSplit = authResult != null ? new QueryAuthSplit(split, 
authResult) : split;
-        LinkedHashSet<BlobViewStruct> viewStructs = new LinkedHashSet<>();
-        RecordReader<InternalRow> prescanReader = 
prescanRead.createReader(prescanSplit);
-        try {
-            prescanReader.forEachRemaining(
-                    row -> {
-                        for (int i = 0; i < blobViewFields.length; i++) {
-                            if (row.isNullAt(i)) {
-                                continue;
-                            }
-                            Blob blob = row.getBlob(i);
-                            if (!(blob instanceof BlobView)) {
-                                throw new IllegalArgumentException(
-                                        "blob-view-field requires blob field 
value to be a "
-                                                + "serialized 
BlobViewStruct.");
-                            }
-                            viewStructs.add(((BlobView) blob).viewStruct());
+        int[] blobViewFields =
+                
BlobViewTableReadSupport.blobViewFieldIndexes(currentReadType(), options);
+        if (catalogContext != null && blobViewFields.length > 0) {
+            if (readFactory == null) {
+                throw new IllegalStateException(
+                        "Cannot read blob-view-field fields without a 
readFactory.");
+            }
+            return BlobViewTableReadSupport.createBlobViewReader(
+                    catalogContext,
+                    queryAuthContext.split(),
+                    queryAuthContext.authResult(),
+                    blobViewFields,
+                    currentReadType(),
+                    predicate(),
+                    topN,
+                    limit,
+                    shouldExecuteFilter(),
+                    () -> createDataReader(queryAuthContext.split(), 
queryAuthContext.authResult()),
+                    () -> {
+                        InnerTableRead prescanRead = readFactory.get();
+                        if (shouldExecuteFilter()) {
+                            prescanRead.executeFilter();
                         }
+                        return prescanRead;
                     });
-        } finally {
-            prescanReader.close();
-        }
-
-        BlobViewResolver resolver =
-                BlobViewLookup.createResolver(catalogContext, new 
ArrayList<>(viewStructs));
-
-        RecordReader<InternalRow> reader = createDataReader(split, authResult);
-        Set<Integer> blobViewFieldSet = new HashSet<>();
-        for (int field : blobViewFields) {
-            blobViewFieldSet.add(field);
-        }
-        return reader.transform(row -> new BlobViewResolvingRow(row, 
blobViewFieldSet, resolver));
-    }
-
-    private void configureBlobViewPrescanRead(InnerTableRead prescanRead) {
-        if (topN != null) {
-            prescanRead.withTopN(topN);
-        }
-        if (limit != null) {
-            prescanRead.withLimit(limit);
         }
+        return createDataReader(queryAuthContext.split(), 
queryAuthContext.authResult());
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/KeyValueTableRead.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/KeyValueTableRead.java
index 0b19df5c0c..9c02bb470f 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/KeyValueTableRead.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/KeyValueTableRead.java
@@ -21,6 +21,7 @@ package org.apache.paimon.table.source;
 import org.apache.paimon.CoreOptions;
 import org.apache.paimon.KeyValue;
 import org.apache.paimon.annotation.VisibleForTesting;
+import org.apache.paimon.catalog.CatalogContext;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.disk.IOManager;
 import org.apache.paimon.operation.MergeFileSplitRead;
@@ -53,7 +54,10 @@ import java.util.function.Supplier;
  */
 public final class KeyValueTableRead extends AbstractDataTableRead {
 
+    private final Supplier<MergeFileSplitRead> mergeReadSupplier;
+    private final Supplier<RawFileSplitRead> batchRawReadSupplier;
     private final List<SplitReadProvider> readProviders;
+    @Nullable private final CatalogContext catalogContext;
 
     @Nullable private RowType readType = null;
     private boolean forceKeepDelete = false;
@@ -66,7 +70,18 @@ public final class KeyValueTableRead extends 
AbstractDataTableRead {
             Supplier<MergeFileSplitRead> mergeReadSupplier,
             Supplier<RawFileSplitRead> batchRawReadSupplier,
             TableSchema schema) {
+        this(mergeReadSupplier, batchRawReadSupplier, schema, null);
+    }
+
+    public KeyValueTableRead(
+            Supplier<MergeFileSplitRead> mergeReadSupplier,
+            Supplier<RawFileSplitRead> batchRawReadSupplier,
+            TableSchema schema,
+            @Nullable CatalogContext catalogContext) {
         super(schema);
+        this.mergeReadSupplier = mergeReadSupplier;
+        this.batchRawReadSupplier = batchRawReadSupplier;
+        this.catalogContext = catalogContext;
         this.readProviders =
                 Arrays.asList(
                         new 
PrimaryKeyIndexedSplitReadProvider(batchRawReadSupplier, this::config),
@@ -140,7 +155,51 @@ public final class KeyValueTableRead extends 
AbstractDataTableRead {
 
     @Override
     public RecordReader<InternalRow> createReader(Split split) throws 
IOException {
-        return LimitRecordReader.limit(super.createReader(split), limit);
+        QueryAuthContext queryAuthContext = unwrapQueryAuthSplit(split);
+        RecordReader<InternalRow> reader;
+        if (catalogContext != null) {
+            CoreOptions options = CoreOptions.fromMap(schema().options());
+            int[] blobViewFields =
+                    
BlobViewTableReadSupport.blobViewFieldIndexes(currentReadType(), options);
+            if (blobViewFields.length > 0) {
+                reader =
+                        BlobViewTableReadSupport.createBlobViewReader(
+                                catalogContext,
+                                queryAuthContext.split(),
+                                queryAuthContext.authResult(),
+                                blobViewFields,
+                                currentReadType(),
+                                predicate(),
+                                topN,
+                                limit,
+                                shouldExecuteFilter(),
+                                () ->
+                                        createDataReader(
+                                                queryAuthContext.split(),
+                                                queryAuthContext.authResult()),
+                                this::createBlobViewPrescanRead);
+            } else {
+                reader = createDataReader(queryAuthContext.split(), 
queryAuthContext.authResult());
+            }
+        } else {
+            reader = createDataReader(queryAuthContext.split(), 
queryAuthContext.authResult());
+        }
+        return LimitRecordReader.limit(reader, limit);
+    }
+
+    private InnerTableRead createBlobViewPrescanRead() {
+        KeyValueTableRead read =
+                new KeyValueTableRead(mergeReadSupplier, batchRawReadSupplier, 
schema(), null);
+        if (ioManager != null) {
+            read.withIOManager(ioManager);
+        }
+        if (forceKeepDelete) {
+            read.forceKeepDelete();
+        }
+        if (shouldExecuteFilter()) {
+            read.executeFilter();
+        }
+        return read;
     }
 
     @Override
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/io/PrimaryKeyBlobFileWriterTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/io/PrimaryKeyBlobFileWriterTest.java
index 22c549bf3f..d7f4d2ee0e 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/io/PrimaryKeyBlobFileWriterTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/io/PrimaryKeyBlobFileWriterTest.java
@@ -22,6 +22,7 @@ import org.apache.paimon.CoreOptions;
 import org.apache.paimon.KeyValue;
 import org.apache.paimon.blob.ManagedBlobReferenceFile;
 import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
 import org.apache.paimon.data.Blob;
 import org.apache.paimon.data.BlobRef;
 import org.apache.paimon.data.GenericRow;
@@ -145,4 +146,101 @@ class PrimaryKeyBlobFileWriterTest {
         Path emptySidecar = 
pathFactory.toAlignedPath(emptyMeta.extraFiles().get(0), emptyMeta);
         assertThat(ManagedBlobReferenceFile.read(fileIO, 
emptySidecar)).isEmpty();
     }
+
+    @Test
+    void testPartialUpdateManagedBlobSidecarCollectsIntermediateReferences() 
throws Exception {
+        FileIO fileIO = LocalFileIO.create();
+        Path tablePath = new Path(tempDir.toUri());
+        RowType keyType =
+                new RowType(
+                        Collections.singletonList(
+                                new DataField(
+                                        SpecialFields.KEY_FIELD_ID_START,
+                                        SpecialFields.KEY_FIELD_PREFIX + "id",
+                                        DataTypes.INT())));
+        RowType valueType =
+                new RowType(
+                        java.util.Arrays.asList(
+                                new DataField(0, "id", DataTypes.INT()),
+                                new DataField(1, "name", DataTypes.STRING()),
+                                new DataField(2, "payload", 
DataTypes.BLOB())));
+        Function<String, FileStorePathFactory> pathFactories =
+                format ->
+                        new FileStorePathFactory(
+                                tablePath,
+                                RowType.of(),
+                                
CoreOptions.PARTITION_DEFAULT_NAME.defaultValue(),
+                                format,
+                                CoreOptions.DATA_FILE_PREFIX.defaultValue(),
+                                
CoreOptions.CHANGELOG_FILE_PREFIX.defaultValue(),
+                                
CoreOptions.PARTITION_GENERATE_LEGACY_NAME.defaultValue(),
+                                
CoreOptions.FILE_SUFFIX_INCLUDE_COMPRESSION.defaultValue(),
+                                CoreOptions.FILE_COMPRESSION.defaultValue(),
+                                null,
+                                null,
+                                CoreOptions.ExternalPathStrategy.NONE,
+                                null,
+                                false,
+                                null);
+        Options options = new Options();
+        options.set(CoreOptions.BLOB_FIELD, "payload");
+        options.set(CoreOptions.MERGE_ENGINE, 
CoreOptions.MergeEngine.PARTIAL_UPDATE);
+        options.set(CoreOptions.CHANGELOG_PRODUCER, 
CoreOptions.ChangelogProducer.NONE);
+        KeyValueFileWriterFactory factory =
+                KeyValueFileWriterFactory.builder(
+                                fileIO,
+                                0,
+                                keyType,
+                                valueType,
+                                new FlushingFileFormat("avro"),
+                                pathFactories,
+                                1024 * 1024)
+                        .build(BinaryRow.EMPTY_ROW, 0, new 
CoreOptions(options));
+        DataFilePathFactory pathFactory = factory.pathFactory(0);
+
+        RollingFileWriter<KeyValue, DataFileMeta> writer =
+                factory.createRollingMergeTreeFileWriter(0, FileSource.APPEND);
+
+        Path firstPack =
+                
pathFactory.newPathFromExtension(ManagedBlobReferenceFile.MANAGED_BLOB_SUFFIX);
+        InternalRow first =
+                GenericRow.of(
+                        1,
+                        BinaryString.fromString("a"),
+                        Blob.fromFile(fileIO, firstPack.toString(), 0, 3));
+
+        writer.write(new KeyValue().replace(GenericRow.of(1), 0, 
RowKind.INSERT, first));
+
+        Path secondPack =
+                
pathFactory.newPathFromExtension(ManagedBlobReferenceFile.MANAGED_BLOB_SUFFIX);
+        InternalRow second =
+                GenericRow.of(
+                        1,
+                        BinaryString.fromString("b"),
+                        Blob.fromFile(fileIO, secondPack.toString(), 0, 3));
+        assertThat(secondPack).isNotEqualTo(firstPack);
+
+        writer.write(new KeyValue().replace(GenericRow.of(1), 1, 
RowKind.INSERT, second));
+
+        writer.write(
+                new KeyValue()
+                        .replace(
+                                GenericRow.of(1),
+                                2,
+                                RowKind.INSERT,
+                                GenericRow.of(1, BinaryString.fromString("c"), 
null)));
+
+        writer.close();
+        factory.prepareCommit();
+
+        DataFileMeta meta = writer.result().get(0);
+        
assertThat(meta.extraFiles()).singleElement().asString().endsWith(".blobref");
+        Path sidecar = pathFactory.toAlignedPath(meta.extraFiles().get(0), 
meta);
+        assertThat(ManagedBlobReferenceFile.read(fileIO, sidecar))
+                .containsExactlyInAnyOrder(
+                        new ManagedBlobReferenceFile.Reference(
+                                sidecar.getParent().toString(), 
firstPack.getName()),
+                        new ManagedBlobReferenceFile.Reference(
+                                sidecar.getParent().toString(), 
secondPack.getName()));
+    }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunctionTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunctionTest.java
index 050f4b855b..9de9d22957 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunctionTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunctionTest.java
@@ -19,6 +19,7 @@
 package org.apache.paimon.mergetree.compact;
 
 import org.apache.paimon.KeyValue;
+import org.apache.paimon.data.Blob;
 import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.types.DataType;
@@ -96,6 +97,42 @@ public class PartialUpdateMergeFunctionTest {
         validate(func, 1, null, null, 6, null, null, 6);
     }
 
+    @Test
+    public void testSequenceGroupWithBlobField() {
+        Options options = new Options();
+        options.set("fields.f3.sequence-group", "f1,f2");
+        RowType rowType =
+                RowType.of(DataTypes.INT(), DataTypes.INT(), DataTypes.BLOB(), 
DataTypes.INT());
+        MergeFunction<KeyValue> func =
+                PartialUpdateMergeFunction.factory(options, rowType, 
ImmutableList.of("f0"))
+                        .create();
+        func.reset();
+
+        Blob first = Blob.fromData(new byte[] {1, 2, 3});
+        Blob second = Blob.fromData(new byte[] {4, 5, 6});
+        Blob third = Blob.fromData(new byte[] {7, 8, 9});
+
+        addBlobRow(func, RowKind.INSERT, 1, 10, first, 1);
+        addBlobRow(func, RowKind.INSERT, 1, 20, second, null);
+        // null sequence group should not overwrite f1/f2
+        assertBlobRow(func, 1, 10, first, 1);
+
+        addBlobRow(func, RowKind.INSERT, 1, 30, third, 2);
+        assertBlobRow(func, 1, 30, third, 2);
+
+        // equal sequence should overwrite the entire group
+        addBlobRow(func, RowKind.INSERT, 1, 40, second, 2);
+        assertBlobRow(func, 1, 40, second, 2);
+
+        // valid sequence should overwrite the entire group, including with 
null
+        addBlobRow(func, RowKind.INSERT, 1, 50, null, 3);
+        assertBlobRow(func, 1, 50, null, 3);
+
+        // older sequence should not overwrite
+        addBlobRow(func, RowKind.INSERT, 1, 60, first, 2);
+        assertBlobRow(func, 1, 50, null, 3);
+    }
+
     @Test
     public void testSequenceGroupPartialDelete() {
         Options options = new Options();
@@ -1007,6 +1044,28 @@ public class PartialUpdateMergeFunctionTest {
                 new KeyValue().replace(GenericRow.of(1), sequence++, rowKind, 
GenericRow.of(f)));
     }
 
+    private void addBlobRow(
+            MergeFunction<KeyValue> function,
+            RowKind rowKind,
+            Integer pk,
+            Integer name,
+            Blob payload,
+            Integer ts) {
+        function.add(
+                new KeyValue()
+                        .replace(
+                                GenericRow.of(pk),
+                                sequence++,
+                                rowKind,
+                                GenericRow.of(pk, name, payload, ts)));
+    }
+
+    private void assertBlobRow(
+            MergeFunction<KeyValue> function, Integer pk, Integer name, Blob 
payload, Integer ts) {
+        GenericRow expected = GenericRow.of(pk, name, payload, ts);
+        assertThat(function.getResult().value()).isEqualTo(expected);
+    }
+
     private void validate(MergeFunction<KeyValue> function, Integer... f) {
         assertThat(function.getResult().value()).isEqualTo(GenericRow.of(f));
     }
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 26294112f6..4d3a71a4f2 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
@@ -408,6 +408,8 @@ class SchemaValidationTest {
 
     @Test
     public void testPrimaryKeyInlineBlobDoesNotTriggerManagedRestrictions() {
+        // Partial-update supports scalar blob-descriptor-field, managed 
blob-field, and
+        // blob-view-field on primary-key tables.
         List<DataField> fields =
                 Arrays.asList(
                         new DataField(0, "id", DataTypes.INT()),
@@ -423,6 +425,173 @@ class SchemaValidationTest {
         assertThatCode(() -> 
validateTableSchema(schema)).doesNotThrowAnyException();
     }
 
+    @Test
+    public void testPartialUpdateAllowsBlobViewField() {
+        List<DataField> fields =
+                Arrays.asList(
+                        new DataField(0, "id", DataTypes.INT()),
+                        new DataField(1, "view", DataTypes.BLOB()));
+        Map<String, String> options = new HashMap<>();
+        options.put(BUCKET.key(), "1");
+        options.put(CoreOptions.BLOB_VIEW_FIELD.key(), "view");
+        options.put(CoreOptions.MERGE_ENGINE.key(), "partial-update");
+
+        TableSchema schema =
+                new TableSchema(1, fields, 10, emptyList(), 
singletonList("id"), options, "");
+
+        assertThatCode(() -> 
validateTableSchema(schema)).doesNotThrowAnyException();
+    }
+
+    @Test
+    public void testPartialUpdateAllowsDescriptorWithBlobViewField() {
+        List<DataField> fields =
+                Arrays.asList(
+                        new DataField(0, "id", DataTypes.INT()),
+                        new DataField(1, "payload", DataTypes.BLOB()),
+                        new DataField(2, "view", DataTypes.BLOB()));
+        Map<String, String> options = new HashMap<>();
+        options.put(BUCKET.key(), "1");
+        options.put(CoreOptions.BLOB_DESCRIPTOR_FIELD.key(), "payload");
+        options.put(CoreOptions.BLOB_VIEW_FIELD.key(), "view");
+        options.put(CoreOptions.MERGE_ENGINE.key(), "partial-update");
+
+        TableSchema schema =
+                new TableSchema(1, fields, 10, emptyList(), 
singletonList("id"), options, "");
+
+        assertThatCode(() -> 
validateTableSchema(schema)).doesNotThrowAnyException();
+    }
+
+    @Test
+    public void testPartialUpdateAllowsManagedBlobField() {
+        List<DataField> fields =
+                Arrays.asList(
+                        new DataField(0, "id", DataTypes.INT()),
+                        new DataField(1, "payload", DataTypes.BLOB()));
+        Map<String, String> options = new HashMap<>();
+        options.put(BUCKET.key(), "1");
+        options.put(CoreOptions.BLOB_FIELD.key(), "payload");
+        options.put(CoreOptions.MERGE_ENGINE.key(), "partial-update");
+
+        TableSchema schema =
+                new TableSchema(1, fields, 10, emptyList(), 
singletonList("id"), options, "");
+
+        assertThatCode(() -> 
validateTableSchema(schema)).doesNotThrowAnyException();
+    }
+
+    @Test
+    public void testPartialUpdateRejectsManagedBlobRetractAggregation() {
+        List<DataField> fields =
+                Arrays.asList(
+                        new DataField(0, "id", DataTypes.INT()),
+                        new DataField(1, "payload", DataTypes.BLOB()),
+                        new DataField(2, "ts", DataTypes.INT()));
+        Map<String, String> options = new HashMap<>();
+        options.put(BUCKET.key(), "1");
+        options.put(CoreOptions.BLOB_FIELD.key(), "payload");
+        options.put(CoreOptions.MERGE_ENGINE.key(), "partial-update");
+        options.put("fields.ts.sequence-group", "payload");
+        options.put("fields.payload.aggregate-function", 
"last_non_null_value");
+
+        TableSchema schema =
+                new TableSchema(1, fields, 10, emptyList(), 
singletonList("id"), options, "");
+        assertThatThrownBy(() -> validateTableSchema(schema))
+                .hasMessageContaining(
+                        "Managed BLOB field 'payload' cannot use aggregate 
function "
+                                + "'last_non_null_value'")
+                .hasMessageContaining("fields.payload.ignore-retract");
+
+        options.put("fields.payload.ignore-retract", "true");
+        assertThatCode(() -> 
validateTableSchema(schema)).doesNotThrowAnyException();
+
+        options.remove("fields.payload.ignore-retract");
+        options.put("fields.payload.aggregate-function", "last_value");
+        assertThatCode(() -> 
validateTableSchema(schema)).doesNotThrowAnyException();
+
+        options.put("fields.payload.aggregate-function", 
"last_non_null_value");
+        options.put(CoreOptions.IGNORE_DELETE.key(), "true");
+        assertThatCode(() -> 
validateTableSchema(schema)).doesNotThrowAnyException();
+
+        options.remove(CoreOptions.IGNORE_DELETE.key());
+        options.remove("fields.ts.sequence-group");
+        assertThatCode(() -> 
validateTableSchema(schema)).doesNotThrowAnyException();
+
+        options.remove("fields.payload.aggregate-function");
+        options.put("fields.ts.sequence-group", "payload");
+        options.put("fields.default-aggregate-function", 
"last_non_null_value");
+        assertThatThrownBy(() -> validateTableSchema(schema))
+                .hasMessageContaining(
+                        "Managed BLOB field 'payload' cannot use aggregate 
function "
+                                + "'last_non_null_value'");
+    }
+
+    @Test
+    public void testPartialUpdateRejectsBlobSequenceGroupOrderingFields() {
+        List<DataType> unsupportedTypes =
+                Arrays.asList(
+                        DataTypes.BLOB(),
+                        DataTypes.ARRAY(DataTypes.BLOB()),
+                        DataTypes.MAP(DataTypes.STRING(), DataTypes.BLOB()));
+        for (DataType unsupportedType : unsupportedTypes) {
+            List<DataField> fields =
+                    Arrays.asList(
+                            new DataField(0, "id", DataTypes.INT()),
+                            new DataField(1, "ordering", unsupportedType),
+                            new DataField(2, "payload", DataTypes.INT()),
+                            new DataField(3, "ts", DataTypes.INT()));
+            Map<String, String> options = new HashMap<>();
+            options.put(BUCKET.key(), "1");
+            options.put(CoreOptions.BLOB_FIELD.key(), "ordering");
+            options.put(CoreOptions.MERGE_ENGINE.key(), "partial-update");
+            options.put("fields.ordering.sequence-group", "payload");
+            TableSchema schema =
+                    new TableSchema(1, fields, 10, emptyList(), 
singletonList("id"), options, "");
+
+            assertThatThrownBy(() -> validateTableSchema(schema))
+                    .as("ordering type %s", unsupportedType)
+                    .hasMessageContaining("Field 'ordering' with type")
+                    .hasMessageContaining("cannot be used as a sequence-group 
ordering field")
+                    .hasMessageContaining("fields.ordering.sequence-group");
+
+            options.remove("fields.ordering.sequence-group");
+            options.put("fields.ts,ordering.sequence-group", "payload");
+            assertThatThrownBy(() -> validateTableSchema(schema))
+                    .as("multi-field ordering type %s", unsupportedType)
+                    .hasMessageContaining("Field 'ordering' with type")
+                    .hasMessageContaining("fields.ts,ordering.sequence-group");
+
+            options.remove("fields.ts,ordering.sequence-group");
+            options.put("fields.ts.sequence-group", "ordering,payload");
+            assertThatCode(() -> validateTableSchema(schema))
+                    .as("protected type %s", unsupportedType)
+                    .doesNotThrowAnyException();
+
+            options.remove("fields.ts.sequence-group");
+            options.put("fields.ordering.sequence-group", "payload");
+            options.put(CoreOptions.MERGE_ENGINE.key(), "deduplicate");
+            assertThatCode(() -> validateTableSchema(schema))
+                    .as("dormant ordering option with type %s", 
unsupportedType)
+                    .doesNotThrowAnyException();
+        }
+    }
+
+    @Test
+    public void testPartialUpdateRejectsMalformedSequenceGroupOption() {
+        List<DataField> fields =
+                Arrays.asList(
+                        new DataField(0, "id", DataTypes.INT()),
+                        new DataField(1, "payload", DataTypes.INT()),
+                        new DataField(2, "ts", DataTypes.INT()));
+        Map<String, String> options = new HashMap<>();
+        options.put(BUCKET.key(), "1");
+        options.put(CoreOptions.MERGE_ENGINE.key(), "partial-update");
+        options.put("fields.ts.xsequence-group", "payload");
+        TableSchema schema =
+                new TableSchema(1, fields, 10, emptyList(), 
singletonList("id"), options, "");
+
+        assertThatThrownBy(() -> validateTableSchema(schema))
+                .hasMessageContaining("Invalid sequence-group option: 
fields.ts.xsequence-group");
+    }
+
     @Test
     public void testPrimaryKeyBlobViewCoexistsWithManagedBlob() {
         List<DataField> fields =
@@ -466,12 +635,31 @@ class SchemaValidationTest {
         Map<String, String> mergeOptions = new HashMap<>(options);
         mergeOptions.put(CoreOptions.MERGE_ENGINE.key(), "partial-update");
         assertThatThrownBy(
+                        () ->
+                                validateTableSchema(
+                                        primaryKeyBlobSchema(
+                                                mergeOptions,
+                                                singletonList("payload"),
+                                                emptyList())))
+                .hasMessage("Managed BLOB fields cannot be primary keys: 
[payload].");
+
+        Map<String, String> mergeSequenceOptions = new HashMap<>(mergeOptions);
+        mergeSequenceOptions.put(CoreOptions.SEQUENCE_FIELD.key(), "payload");
+        assertThatThrownBy(
+                        () ->
+                                validateTableSchema(
+                                        primaryKeyBlobSchema(
+                                                mergeSequenceOptions,
+                                                singletonList("id"),
+                                                emptyList())))
+                .hasMessage("Managed BLOB fields cannot be sequence fields: 
[payload].");
+
+        assertThatCode(
                         () ->
                                 validateTableSchema(
                                         primaryKeyBlobSchema(
                                                 mergeOptions, 
singletonList("id"), emptyList())))
-                .hasMessage(
-                        "Primary-key managed BLOB tables only support the 
deduplicate merge engine.");
+                .doesNotThrowAnyException();
 
         Map<String, String> changelogOptions = new HashMap<>(options);
         changelogOptions.put(CoreOptions.CHANGELOG_PRODUCER.key(), "input");
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/PrimaryKeyPartialUpdateBlobTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/PrimaryKeyPartialUpdateBlobTest.java
new file mode 100644
index 0000000000..c5ef165282
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/PrimaryKeyPartialUpdateBlobTest.java
@@ -0,0 +1,441 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.table;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.blob.ManagedBlobReferenceFile;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.Blob;
+import org.apache.paimon.data.BlobData;
+import org.apache.paimon.data.BlobView;
+import org.apache.paimon.data.BlobViewStruct;
+import org.apache.paimon.data.GenericArray;
+import org.apache.paimon.data.GenericMap;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalArray;
+import org.apache.paimon.data.InternalMap;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataFilePathFactory;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.table.sink.StreamTableCommit;
+import org.apache.paimon.table.sink.StreamTableWrite;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.InnerTableRead;
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.table.system.RowTrackingTable;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowKind;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for primary-key partial-update tables with managed and view BLOB 
fields. */
+public class PrimaryKeyPartialUpdateBlobTest extends TableTestBase {
+
+    @Test
+    public void testPartialUpdateManagedBlobMergeAndCompact() throws Exception 
{
+        String tableName = "pk_pu_managed_blob";
+        Schema schema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("name", DataTypes.STRING())
+                        .column("payload", DataTypes.BLOB())
+                        .primaryKey("id")
+                        .option(CoreOptions.MERGE_ENGINE.key(), 
"partial-update")
+                        .option(CoreOptions.BLOB_FIELD.key(), "payload")
+                        .option(CoreOptions.CHANGELOG_PRODUCER.key(), "none")
+                        .option(CoreOptions.BUCKET.key(), "1")
+                        .build();
+        catalog.createTable(identifier(tableName), schema, true);
+        FileStoreTable table = getTable(identifier(tableName));
+
+        byte[] first = new byte[] {1, 2, 3};
+        byte[] second = new byte[] {4, 5, 6};
+
+        try (StreamTableWrite write = table.newWrite(commitUser);
+                StreamTableCommit commit = table.newCommit(commitUser)) {
+            write.write(GenericRow.of(1, BinaryString.fromString("a"), new 
BlobData(first)));
+            commit.commit(0, write.prepareCommit(false, 0));
+
+            write.write(GenericRow.of(1, BinaryString.fromString("b"), null));
+            commit.commit(1, write.prepareCommit(false, 1));
+
+            write.write(GenericRow.of(1, null, new BlobData(second)));
+            commit.commit(2, write.prepareCommit(false, 2));
+        }
+
+        ReadBuilder readBuilder = table.newReadBuilder();
+        List<InternalRow> rows = readRows(readBuilder);
+        assertThat(rows).hasSize(1);
+        assertThat(rows.get(0).getString(1).toString()).isEqualTo("b");
+        assertThat(rows.get(0).getBlob(2).toData()).isEqualTo(second);
+
+        List<DataFileMeta> dataFiles = listDataFiles(table);
+        assertThat(dataFiles).isNotEmpty();
+        assertThat(dataFiles.get(0).extraFiles())
+                .anyMatch(extraFile -> extraFile.endsWith(".blobref"));
+
+        compact(table, BinaryRow.EMPTY_ROW, 0);
+        rows = readRows(table.newReadBuilder());
+        assertThat(rows).hasSize(1);
+        assertThat(rows.get(0).getBlob(2).toData()).isEqualTo(second);
+        assertThat(listDataFiles(table)).hasSize(1);
+    }
+
+    @Test
+    public void testPartialUpdateManagedBlobLastValueRetract() throws 
Exception {
+        String tableName = "pk_pu_managed_blob_retract";
+        Schema schema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("payload", DataTypes.BLOB())
+                        .column("ts", DataTypes.INT())
+                        .primaryKey("id")
+                        .option(CoreOptions.MERGE_ENGINE.key(), 
"partial-update")
+                        .option(CoreOptions.BLOB_FIELD.key(), "payload")
+                        .option(CoreOptions.CHANGELOG_PRODUCER.key(), "none")
+                        .option("fields.ts.sequence-group", "payload")
+                        .option("fields.payload.aggregate-function", 
"last_value")
+                        .option(CoreOptions.BUCKET.key(), "1")
+                        .build();
+        catalog.createTable(identifier(tableName), schema, true);
+        FileStoreTable table = getTable(identifier(tableName));
+
+        byte[] payload = new byte[] {1, 2, 3};
+        try (StreamTableWrite write = table.newWrite(commitUser);
+                StreamTableCommit commit = table.newCommit(commitUser)) {
+            write.write(GenericRow.of(1, new BlobData(payload), 1));
+            write.write(GenericRow.of(2, new BlobData(payload), 2));
+            commit.commit(0, write.prepareCommit(false, 0));
+
+            write.write(GenericRow.ofKind(RowKind.DELETE, 1, new 
BlobData(payload), 2));
+            write.write(GenericRow.ofKind(RowKind.DELETE, 2, new 
BlobData(payload), 1));
+            commit.commit(1, write.prepareCommit(false, 1));
+        }
+
+        List<InternalRow> rows = readRows(table.newReadBuilder());
+        assertLastValueRetractedRows(rows);
+
+        compact(table, BinaryRow.EMPTY_ROW, 0);
+        rows = readRows(table.newReadBuilder());
+        assertLastValueRetractedRows(rows);
+        assertThat(managedBlobReferences(table)).isEmpty();
+    }
+
+    @Test
+    public void testPartialUpdateManagedBlobCollectionsIgnoreRetract() throws 
Exception {
+        String tableName = "pk_pu_managed_blob_collections_ignore_retract";
+        Schema schema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("payload", DataTypes.BLOB())
+                        .column("payloads", DataTypes.ARRAY(DataTypes.BLOB()))
+                        .column("assets", DataTypes.MAP(DataTypes.STRING(), 
DataTypes.BLOB()))
+                        .column("ts", DataTypes.INT())
+                        .primaryKey("id")
+                        .option(CoreOptions.MERGE_ENGINE.key(), 
"partial-update")
+                        .option(CoreOptions.BLOB_FIELD.key(), 
"payload,payloads,assets")
+                        .option(CoreOptions.CHANGELOG_PRODUCER.key(), "none")
+                        .option("fields.ts.sequence-group", 
"payload,payloads,assets")
+                        .option("fields.default-aggregate-function", 
"last_non_null_value")
+                        .option("fields.payload.ignore-retract", "true")
+                        .option("fields.payloads.ignore-retract", "true")
+                        .option("fields.assets.ignore-retract", "true")
+                        .option(CoreOptions.BUCKET.key(), "1")
+                        .build();
+        catalog.createTable(identifier(tableName), schema, true);
+        FileStoreTable table = getTable(identifier(tableName));
+
+        byte[] payload = new byte[] {1, 2, 3};
+        try (StreamTableWrite write = table.newWrite(commitUser);
+                StreamTableCommit commit = table.newCommit(commitUser)) {
+            write.write(
+                    GenericRow.of(
+                            1,
+                            new BlobData(payload),
+                            new GenericArray(new Object[] {new 
BlobData(payload)}),
+                            blobMap("payload", payload),
+                            1));
+            commit.commit(0, write.prepareCommit(false, 0));
+
+            write.write(
+                    GenericRow.ofKind(
+                            RowKind.DELETE,
+                            1,
+                            new BlobData(payload),
+                            new GenericArray(new Object[] {new 
BlobData(payload)}),
+                            blobMap("payload", payload),
+                            2));
+            commit.commit(1, write.prepareCommit(false, 1));
+        }
+
+        assertManagedIgnoreRetractRow(table, payload);
+        compact(table, BinaryRow.EMPTY_ROW, 0);
+        assertManagedIgnoreRetractRow(table, payload);
+    }
+
+    @Test
+    public void testPartialUpdateManagedBlobCollections() throws Exception {
+        String tableName = "pk_pu_managed_blob_collections";
+        Schema schema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("name", DataTypes.STRING())
+                        .column("payloads", DataTypes.ARRAY(DataTypes.BLOB()))
+                        .column("assets", DataTypes.MAP(DataTypes.STRING(), 
DataTypes.BLOB()))
+                        .primaryKey("id")
+                        .option(CoreOptions.MERGE_ENGINE.key(), 
"partial-update")
+                        .option(CoreOptions.BLOB_FIELD.key(), 
"payloads,assets")
+                        .option(CoreOptions.CHANGELOG_PRODUCER.key(), "none")
+                        .option(CoreOptions.BUCKET.key(), "1")
+                        .build();
+        catalog.createTable(identifier(tableName), schema, true);
+        FileStoreTable table = getTable(identifier(tableName));
+
+        byte[] first = new byte[] {1, 2, 3};
+        byte[] second = new byte[] {4, 5, 6};
+        try (StreamTableWrite write = table.newWrite(commitUser);
+                StreamTableCommit commit = table.newCommit(commitUser)) {
+            write.write(
+                    GenericRow.of(
+                            1,
+                            BinaryString.fromString("a"),
+                            new GenericArray(new Object[] {new 
BlobData(first), null}),
+                            blobMap("first", first)));
+            commit.commit(0, write.prepareCommit(false, 0));
+
+            write.write(GenericRow.of(1, BinaryString.fromString("b"), null, 
null));
+            commit.commit(1, write.prepareCommit(false, 1));
+        }
+
+        assertManagedCollectionRow(table, "b", first, "first", first, 2);
+
+        try (StreamTableWrite write = table.newWrite(commitUser);
+                StreamTableCommit commit = table.newCommit(commitUser)) {
+            write.write(
+                    GenericRow.of(
+                            1,
+                            null,
+                            new GenericArray(new Object[] {new 
BlobData(second)}),
+                            blobMap("second", second)));
+            commit.commit(2, write.prepareCommit(false, 2));
+        }
+
+        assertManagedCollectionRow(table, "b", second, "second", second, 1);
+        compact(table, BinaryRow.EMPTY_ROW, 0);
+        assertManagedCollectionRow(table, "b", second, "second", second, 1);
+    }
+
+    @Test
+    public void testPartialUpdateBlobViewResolvesOnRead() throws Exception {
+        String upstreamName = "pk_pu_upstream_blob";
+        Schema upstreamSchema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("image", DataTypes.BLOB())
+                        .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
+                        .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(), 
"true")
+                        .option(CoreOptions.BLOB_FIELD.key(), "image")
+                        .build();
+        catalog.createTable(identifier(upstreamName), upstreamSchema, true);
+        FileStoreTable upstreamTable = getTable(identifier(upstreamName));
+
+        byte[] imageBytes = new byte[] {72, 101, 108, 108, 111};
+        byte[] secondImageBytes = new byte[] {87, 111, 114, 108, 100};
+        write(
+                upstreamTable,
+                GenericRow.of(1, new BlobData(imageBytes)),
+                GenericRow.of(2, new BlobData(secondImageBytes)));
+
+        int imageFieldId = upstreamTable.rowType().getField("image").id();
+        RowTrackingTable upstreamRowTracking = new 
RowTrackingTable(upstreamTable);
+        ReadBuilder rowIdReader =
+                upstreamRowTracking.newReadBuilder().withProjection(new int[] 
{0, 2});
+        Map<Integer, Long> idToRowId = new HashMap<>();
+        rowIdReader
+                .newRead()
+                .createReader(rowIdReader.newScan().plan())
+                .forEachRemaining(row -> idToRowId.put(row.getInt(0), 
row.getLong(1)));
+        assertThat(idToRowId).containsKeys(1, 2);
+
+        String downstreamName = "pk_pu_blob_view";
+        Schema downstreamSchema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("label", DataTypes.STRING())
+                        .column("image_ref", DataTypes.BLOB())
+                        .primaryKey("id")
+                        .option(CoreOptions.MERGE_ENGINE.key(), 
"partial-update")
+                        .option(CoreOptions.BLOB_VIEW_FIELD.key(), "image_ref")
+                        .option(CoreOptions.BUCKET.key(), "1")
+                        .build();
+        catalog.createTable(identifier(downstreamName), downstreamSchema, 
true);
+        FileStoreTable downstreamTable = getTable(identifier(downstreamName));
+
+        String upstreamFullName = database + "." + upstreamName;
+        write(
+                downstreamTable,
+                GenericRow.of(
+                        1,
+                        BinaryString.fromString("label1"),
+                        Blob.fromView(
+                                new BlobViewStruct(
+                                        
Identifier.fromString(upstreamFullName),
+                                        imageFieldId,
+                                        idToRowId.get(1)))),
+                GenericRow.of(
+                        2,
+                        BinaryString.fromString("label2"),
+                        Blob.fromView(
+                                new BlobViewStruct(
+                                        
Identifier.fromString(upstreamFullName),
+                                        imageFieldId,
+                                        idToRowId.get(2)))));
+
+        try (StreamTableWrite write = downstreamTable.newWrite(commitUser);
+                StreamTableCommit commit = 
downstreamTable.newCommit(commitUser)) {
+            write.write(GenericRow.of(1, BinaryString.fromString("updated"), 
null));
+            commit.commit(0, write.prepareCommit(false, 0));
+        }
+
+        ReadBuilder readBuilder = downstreamTable.newReadBuilder();
+        List<InternalRow> rows = readRows(readBuilder);
+        assertThat(rows).hasSize(2);
+        InternalRow updatedRow =
+                rows.stream()
+                        .filter(row -> row.getInt(0) == 1)
+                        .findFirst()
+                        .orElseThrow(AssertionError::new);
+        assertThat(updatedRow.getString(1).toString()).isEqualTo("updated");
+        Blob blob = updatedRow.getBlob(2);
+        assertThat(blob).isInstanceOf(BlobView.class);
+        assertThat(((BlobView) blob).isResolved()).isTrue();
+        assertThat(blob.toData()).isEqualTo(imageBytes);
+
+        PredicateBuilder predicateBuilder = new 
PredicateBuilder(downstreamTable.rowType());
+        ReadBuilder filteredReadBuilder =
+                
downstreamTable.newReadBuilder().withFilter(predicateBuilder.equal(0, 2));
+        InnerTableRead filteredRead = (InnerTableRead) 
filteredReadBuilder.newRead();
+        filteredRead.withLimit(1);
+        filteredRead.executeFilter();
+        rows = 
read(filteredRead.createReader(filteredReadBuilder.newScan().plan()));
+        assertThat(rows).hasSize(1);
+        assertThat(rows.get(0).getInt(0)).isEqualTo(2);
+        
assertThat(rows.get(0).getBlob(2).toData()).isEqualTo(secondImageBytes);
+    }
+
+    private List<InternalRow> readRows(ReadBuilder readBuilder) throws 
Exception {
+        RecordReader<InternalRow> reader =
+                
readBuilder.newRead().createReader(readBuilder.newScan().plan());
+        return read(reader);
+    }
+
+    private void assertManagedCollectionRow(
+            FileStoreTable table,
+            String expectedName,
+            byte[] expectedArrayValue,
+            String expectedMapKey,
+            byte[] expectedMapValue,
+            int expectedArraySize)
+            throws Exception {
+        List<InternalRow> rows = readRows(table.newReadBuilder());
+        assertThat(rows).hasSize(1);
+        InternalRow row = rows.get(0);
+        assertThat(row.getString(1).toString()).isEqualTo(expectedName);
+
+        InternalArray payloads = row.getArray(2);
+        assertThat(payloads.size()).isEqualTo(expectedArraySize);
+        assertThat(payloads.getBlob(0).toData()).isEqualTo(expectedArrayValue);
+        if (expectedArraySize > 1) {
+            assertThat(payloads.isNullAt(1)).isTrue();
+        }
+
+        InternalMap assets = row.getMap(3);
+        assertThat(assets.size()).isEqualTo(1);
+        
assertThat(assets.keyArray().getString(0).toString()).isEqualTo(expectedMapKey);
+        
assertThat(assets.valueArray().getBlob(0).toData()).isEqualTo(expectedMapValue);
+    }
+
+    private GenericMap blobMap(String key, byte[] value) {
+        Map<BinaryString, Blob> blobs = new LinkedHashMap<>();
+        blobs.put(BinaryString.fromString(key), new BlobData(value));
+        return new GenericMap(blobs);
+    }
+
+    private void assertManagedIgnoreRetractRow(FileStoreTable table, byte[] 
expected)
+            throws Exception {
+        List<InternalRow> rows = readRows(table.newReadBuilder());
+        assertThat(rows).hasSize(1);
+        InternalRow row = rows.get(0);
+        assertThat(row.getBlob(1).toData()).isEqualTo(expected);
+        assertThat(row.getArray(2).getBlob(0).toData()).isEqualTo(expected);
+        
assertThat(row.getMap(3).valueArray().getBlob(0).toData()).isEqualTo(expected);
+        assertThat(row.getInt(4)).isEqualTo(2);
+    }
+
+    private void assertLastValueRetractedRows(List<InternalRow> rows) {
+        assertThat(rows).hasSize(2);
+        assertThat(rows).allMatch(row -> row.isNullAt(1));
+        assertThat(rows).allMatch(row -> row.getInt(2) == 2);
+    }
+
+    private List<ManagedBlobReferenceFile.Reference> 
managedBlobReferences(FileStoreTable table)
+            throws Exception {
+        List<DataFileMeta> dataFiles = listDataFiles(table);
+        assertThat(dataFiles).hasSize(1);
+        DataFileMeta dataFile = dataFiles.get(0);
+        String referenceFile =
+                dataFile.extraFiles().stream()
+                        .filter(
+                                file ->
+                                        file.endsWith(
+                                                
ManagedBlobReferenceFile.REFERENCE_FILE_SUFFIX))
+                        .findFirst()
+                        .orElseThrow(() -> new AssertionError("Missing managed 
BLOB sidecar."));
+        DataFilePathFactory pathFactory =
+                
table.store().pathFactory().createDataFilePathFactory(BinaryRow.EMPTY_ROW, 0);
+        return ManagedBlobReferenceFile.read(
+                table.fileIO(), pathFactory.toAlignedPath(referenceFile, 
dataFile));
+    }
+
+    private List<DataFileMeta> listDataFiles(FileStoreTable table) {
+        return table.newSnapshotReader().read().dataSplits().stream()
+                .flatMap(split -> ((DataSplit) split).dataFiles().stream())
+                .collect(Collectors.toList());
+    }
+
+    private List<InternalRow> read(RecordReader<InternalRow> reader) throws 
Exception {
+        List<InternalRow> rows = new java.util.ArrayList<>();
+        reader.forEachRemaining(rows::add);
+        reader.close();
+        return rows;
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/BlobViewTableReadSupportTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/source/BlobViewTableReadSupportTest.java
new file mode 100644
index 0000000000..6dbd655740
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/BlobViewTableReadSupportTest.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.table.source;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link BlobViewTableReadSupport}. */
+class BlobViewTableReadSupportTest {
+
+    @Test
+    void testBlobViewFieldIndexesWhenResolveDisabled() {
+        RowType rowType =
+                RowType.of(
+                        new DataField(0, "id", DataTypes.INT()),
+                        new DataField(1, "view", DataTypes.BLOB()));
+        Options options = new Options();
+        options.set(CoreOptions.BLOB_VIEW_FIELD, "view");
+        options.set(CoreOptions.BLOB_VIEW_RESOLVE_ENABLED, false);
+
+        assertThat(BlobViewTableReadSupport.blobViewFieldIndexes(rowType, new 
CoreOptions(options)))
+                .isEmpty();
+    }
+
+    @Test
+    void testBlobViewFieldIndexesReturnsProjectedIndexes() {
+        RowType rowType =
+                RowType.of(
+                        new DataField(0, "id", DataTypes.INT()),
+                        new DataField(1, "label", DataTypes.STRING()),
+                        new DataField(2, "view", DataTypes.BLOB()));
+        Options options = new Options();
+        options.set(CoreOptions.BLOB_VIEW_FIELD, "view");
+
+        assertThat(BlobViewTableReadSupport.blobViewFieldIndexes(rowType, new 
CoreOptions(options)))
+                .containsExactly(2);
+    }
+
+    @Test
+    void testBlobViewFieldIndexesIgnoresNonConfiguredFields() {
+        RowType rowType =
+                RowType.of(
+                        new DataField(0, "id", DataTypes.INT()),
+                        new DataField(1, "payload", DataTypes.BLOB()),
+                        new DataField(2, "view", DataTypes.BLOB()));
+        Options options = new Options();
+        options.set(CoreOptions.BLOB_VIEW_FIELD, "view");
+
+        assertThat(BlobViewTableReadSupport.blobViewFieldIndexes(rowType, new 
CoreOptions(options)))
+                .containsExactly(2);
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/PartialUpdateITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/PartialUpdateITCase.java
index c1fcc2ca09..681821f190 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/PartialUpdateITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/PartialUpdateITCase.java
@@ -18,11 +18,17 @@
 
 package org.apache.paimon.flink;
 
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.data.BlobDescriptor;
+import org.apache.paimon.data.BlobViewStruct;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.local.LocalFileIO;
 import org.apache.paimon.utils.BlockingIterator;
 import org.apache.paimon.utils.CommonTestUtils;
 
 import org.apache.flink.configuration.RestartStrategyOptions;
 import org.apache.flink.table.api.config.ExecutionConfigOptions;
+import org.apache.flink.table.api.config.TableConfigOptions;
 import org.apache.flink.table.planner.factories.TestValuesTableFactory;
 import org.apache.flink.types.Row;
 import org.apache.flink.types.RowKind;
@@ -32,6 +38,7 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 
+import java.io.OutputStream;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -772,4 +779,366 @@ public class PartialUpdateITCase extends 
CatalogITCaseBase {
 
         assertThat(sql("SELECT * FROM 
seq_default_agg")).containsExactly(Row.of(0, 2, 3));
     }
+
+    @Test
+    public void testBlobDescriptorPartialUpdate() throws Exception {
+        byte[] first = "video-v1".getBytes();
+        byte[] second = "video-v2".getBytes();
+        String firstUri = writeExternalBlob("pu_blob_v1", first);
+        String secondUri = writeExternalBlob("pu_blob_v2", second);
+
+        sql(
+                "CREATE TABLE pu_blob ("
+                        + " id INT PRIMARY KEY NOT ENFORCED,"
+                        + " name STRING,"
+                        + " payload BYTES"
+                        + ") WITH ("
+                        + " 'merge-engine'='partial-update',"
+                        + " 'blob-descriptor-field'='payload',"
+                        + " 'bucket'='1',"
+                        + " 'write-only'='true',"
+                        + " 'num-sorted-run.compaction-trigger'='100'"
+                        + ")");
+
+        sql("INSERT INTO pu_blob VALUES (1, 'a', sys.path_to_descriptor('" + 
firstUri + "'))");
+        sql("INSERT INTO pu_blob VALUES (1, 'b', CAST(NULL AS BYTES))");
+        assertThat(sql("SELECT id, name, payload FROM pu_blob"))
+                .containsExactly(Row.of(1, "b", first));
+
+        sql(
+                "INSERT INTO pu_blob VALUES (1, CAST(NULL AS STRING), 
sys.path_to_descriptor('"
+                        + secondUri
+                        + "'))");
+        assertThat(sql("SELECT id, name, payload FROM pu_blob"))
+                .containsExactly(Row.of(1, "b", second));
+
+        assertThat((long) sql("SELECT COUNT(*) FROM 
`pu_blob$files`").get(0).getField(0))
+                .isGreaterThan(1);
+        tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+        sql("CALL sys.compact(`table` => 'default.pu_blob', compact_strategy 
=> 'full')");
+        Snapshot snapshot = findLatestSnapshot("pu_blob");
+        assertThat(snapshot).isNotNull();
+        
assertThat(snapshot.commitKind()).isEqualTo(Snapshot.CommitKind.COMPACT);
+        assertThat(sql("SELECT COUNT(*) FROM 
`pu_blob$files`")).containsExactly(Row.of(1L));
+        assertThat(sql("SELECT id, name, payload FROM pu_blob"))
+                .containsExactly(Row.of(1, "b", second));
+
+        sql("ALTER TABLE pu_blob SET ('blob-as-descriptor'='true')");
+        byte[] descriptorBytes = (byte[]) sql("SELECT payload FROM 
pu_blob").get(0).getField(0);
+        BlobDescriptor descriptor = 
BlobDescriptor.deserialize(descriptorBytes);
+        assertThat(descriptor.uri()).isEqualTo(secondUri);
+        assertThat(descriptor.offset()).isEqualTo(0);
+        // path_to_descriptor may encode whole-file length as -1.
+        assertThat(descriptor.length()).isIn(-1L, (long) second.length);
+    }
+
+    @Test
+    public void testBlobDescriptorPartialUpdateSequenceGroup() throws 
Exception {
+        byte[] first = "seq-v1".getBytes();
+        byte[] second = "seq-v2".getBytes();
+        String firstUri = writeExternalBlob("pu_blob_seq_v1", first);
+        String secondUri = writeExternalBlob("pu_blob_seq_v2", second);
+
+        sql(
+                "CREATE TABLE pu_blob_seq ("
+                        + " id INT PRIMARY KEY NOT ENFORCED,"
+                        + " name STRING,"
+                        + " payload BYTES,"
+                        + " ts INT"
+                        + ") WITH ("
+                        + " 'merge-engine'='partial-update',"
+                        + " 'blob-descriptor-field'='payload',"
+                        + " 'fields.ts.sequence-group'='name,payload',"
+                        + " 'bucket'='1',"
+                        + " 'write-only'='true',"
+                        + " 'num-sorted-run.compaction-trigger'='100'"
+                        + ")");
+
+        sql(
+                "INSERT INTO pu_blob_seq VALUES (1, 'a', 
sys.path_to_descriptor('"
+                        + firstUri
+                        + "'), 1)");
+        // null sequence should not overwrite name/payload
+        sql(
+                "INSERT INTO pu_blob_seq VALUES (1, 'b', 
sys.path_to_descriptor('"
+                        + secondUri
+                        + "'), CAST(NULL AS INT))");
+        assertThat(sql("SELECT id, name, payload, ts FROM pu_blob_seq"))
+                .containsExactly(Row.of(1, "a", first, 1));
+
+        sql(
+                "INSERT INTO pu_blob_seq VALUES (1, 'c', 
sys.path_to_descriptor('"
+                        + secondUri
+                        + "'), 2)");
+        assertThat(sql("SELECT id, name, payload, ts FROM pu_blob_seq"))
+                .containsExactly(Row.of(1, "c", second, 2));
+
+        // equal sequence overwrites the entire group, including with null
+        sql("INSERT INTO pu_blob_seq VALUES " + "(1, 'd', CAST(NULL AS BYTES), 
2)");
+        assertThat(sql("SELECT id, name, payload, ts FROM pu_blob_seq"))
+                .containsExactly(Row.of(1, "d", null, 2));
+
+        // older sequence must not overwrite
+        sql(
+                "INSERT INTO pu_blob_seq VALUES (1, 'e', 
sys.path_to_descriptor('"
+                        + secondUri
+                        + "'), 1)");
+        assertThat(sql("SELECT id, name, payload, ts FROM pu_blob_seq"))
+                .containsExactly(Row.of(1, "d", null, 2));
+
+        assertThat((long) sql("SELECT COUNT(*) FROM 
`pu_blob_seq$files`").get(0).getField(0))
+                .isGreaterThan(1);
+        tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+        sql("CALL sys.compact(`table` => 'default.pu_blob_seq', 
compact_strategy => 'full')");
+        Snapshot snapshot = findLatestSnapshot("pu_blob_seq");
+        assertThat(snapshot).isNotNull();
+        
assertThat(snapshot.commitKind()).isEqualTo(Snapshot.CommitKind.COMPACT);
+        assertThat(sql("SELECT COUNT(*) FROM 
`pu_blob_seq$files`")).containsExactly(Row.of(1L));
+        assertThat(sql("SELECT id, name, payload, ts FROM pu_blob_seq"))
+                .containsExactly(Row.of(1, "d", null, 2));
+
+        sql("ALTER TABLE pu_blob_seq SET ('blob-as-descriptor'='true')");
+        assertThat(sql("SELECT payload FROM 
pu_blob_seq")).containsExactly(Row.of((Object) null));
+    }
+
+    @Test
+    public void testManagedBlobPartialUpdate() throws Exception {
+        byte[] first = "blob-v1".getBytes();
+        byte[] second = "blob-v2".getBytes();
+
+        sql(
+                "CREATE TABLE pu_managed_blob ("
+                        + " id INT PRIMARY KEY NOT ENFORCED,"
+                        + " name STRING,"
+                        + " payload BYTES"
+                        + ") WITH ("
+                        + " 'merge-engine'='partial-update',"
+                        + " 'blob-field'='payload',"
+                        + " 'changelog-producer'='none',"
+                        + " 'bucket'='1',"
+                        + " 'write-only'='true',"
+                        + " 'num-sorted-run.compaction-trigger'='100'"
+                        + ")");
+
+        sql("INSERT INTO pu_managed_blob VALUES (1, 'a', " + 
toHexLiteral(first) + ")");
+        sql("INSERT INTO pu_managed_blob VALUES (1, 'b', CAST(NULL AS 
BYTES))");
+        assertThat(sql("SELECT id, name, payload FROM pu_managed_blob"))
+                .containsExactly(Row.of(1, "b", first));
+
+        sql(
+                "INSERT INTO pu_managed_blob VALUES (1, CAST(NULL AS STRING), "
+                        + toHexLiteral(second)
+                        + ")");
+        assertThat(sql("SELECT id, name, payload FROM pu_managed_blob"))
+                .containsExactly(Row.of(1, "b", second));
+
+        assertThat((long) sql("SELECT COUNT(*) FROM 
`pu_managed_blob$files`").get(0).getField(0))
+                .isGreaterThan(1);
+        tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+        sql("CALL sys.compact(`table` => 'default.pu_managed_blob', 
compact_strategy => 'full')");
+        Snapshot snapshot = findLatestSnapshot("pu_managed_blob");
+        assertThat(snapshot).isNotNull();
+        
assertThat(snapshot.commitKind()).isEqualTo(Snapshot.CommitKind.COMPACT);
+        assertThat(sql("SELECT COUNT(*) FROM 
`pu_managed_blob$files`")).containsExactly(Row.of(1L));
+        assertThat(sql("SELECT id, name, payload FROM pu_managed_blob"))
+                .containsExactly(Row.of(1, "b", second));
+    }
+
+    @Test
+    public void testManagedBlobPartialUpdateSequenceGroup() throws Exception {
+        byte[] first = "blob-seq-v1".getBytes();
+        byte[] second = "blob-seq-v2".getBytes();
+        sql(
+                "CREATE TABLE pu_managed_blob_seq ("
+                        + " id INT PRIMARY KEY NOT ENFORCED,"
+                        + " name STRING,"
+                        + " payload BYTES,"
+                        + " ts INT"
+                        + ") WITH ("
+                        + " 'merge-engine'='partial-update',"
+                        + " 'blob-field'='payload',"
+                        + " 'changelog-producer'='none',"
+                        + " 'fields.ts.sequence-group'='name,payload',"
+                        + " 'bucket'='1',"
+                        + " 'write-only'='true',"
+                        + " 'num-sorted-run.compaction-trigger'='100'"
+                        + ")");
+
+        sql("INSERT INTO pu_managed_blob_seq VALUES (1, 'first', " + 
toHexLiteral(first) + ", 2)");
+        sql("INSERT INTO pu_managed_blob_seq VALUES (1, 'older', " + 
toHexLiteral(second) + ", 1)");
+        assertThat(sql("SELECT id, name, payload, ts FROM 
pu_managed_blob_seq"))
+                .containsExactly(Row.of(1, "first", first, 2));
+
+        sql("INSERT INTO pu_managed_blob_seq VALUES (1, 'cleared', CAST(NULL 
AS BYTES), 3)");
+        assertThat(sql("SELECT id, name, payload, ts FROM 
pu_managed_blob_seq"))
+                .containsExactly(Row.of(1, "cleared", null, 3));
+
+        tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+        sql(
+                "CALL sys.compact("
+                        + "`table` => 'default.pu_managed_blob_seq', "
+                        + "compact_strategy => 'full')");
+        assertThat(sql("SELECT id, name, payload, ts FROM 
pu_managed_blob_seq"))
+                .containsExactly(Row.of(1, "cleared", null, 3));
+    }
+
+    @Test
+    public void testBlobViewPartialUpdate() throws Exception {
+        createBlobViewPartialUpdateTables();
+
+        assertThat(sql("SELECT id, label, image_ref FROM pu_blob_view ORDER BY 
id"))
+                .containsExactly(Row.of(1, "row1", new byte[] {72, 101, 108, 
108, 111}));
+
+        sql("INSERT INTO pu_blob_view VALUES (1, 'updated', CAST(NULL AS 
BYTES))");
+        assertThat(sql("SELECT id, label, image_ref FROM pu_blob_view"))
+                .containsExactly(Row.of(1, "updated", new byte[] {72, 101, 
108, 108, 111}));
+
+        assertThat((long) sql("SELECT COUNT(*) FROM 
`pu_blob_view$files`").get(0).getField(0))
+                .isGreaterThan(1);
+        tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+        sql("CALL sys.compact(`table` => 'default.pu_blob_view', 
compact_strategy => 'full')");
+        Snapshot snapshot = findLatestSnapshot("pu_blob_view");
+        assertThat(snapshot).isNotNull();
+        
assertThat(snapshot.commitKind()).isEqualTo(Snapshot.CommitKind.COMPACT);
+        assertThat(sql("SELECT COUNT(*) FROM 
`pu_blob_view$files`")).containsExactly(Row.of(1L));
+        assertThat(sql("SELECT id, label, image_ref FROM pu_blob_view"))
+                .containsExactly(Row.of(1, "updated", new byte[] {72, 101, 
108, 108, 111}));
+    }
+
+    @Test
+    public void testBlobViewPartialUpdateForwardReference() throws Exception {
+        String fullTableName = createBlobViewPartialUpdateTables();
+        sql("INSERT INTO pu_blob_view VALUES (1, 'updated', CAST(NULL AS 
BYTES))");
+        sql(
+                "CREATE TABLE pu_blob_view_forward ("
+                        + " id INT PRIMARY KEY NOT ENFORCED,"
+                        + " label STRING,"
+                        + " image_ref BYTES"
+                        + ") WITH ("
+                        + " 'merge-engine'='partial-update',"
+                        + " 'blob-view-field'='image_ref'"
+                        + ")");
+        sql(
+                "INSERT INTO pu_blob_view_forward"
+                        + " SELECT id, label, image_ref"
+                        + " FROM pu_blob_view"
+                        + " /*+ OPTIONS('blob-view.resolve.enabled'='false') 
*/");
+
+        assertThat(sql("SELECT id, label, image_ref FROM 
pu_blob_view_forward"))
+                .containsExactly(Row.of(1, "updated", new byte[] {72, 101, 
108, 108, 111}));
+
+        byte[] originalReference =
+                (byte[])
+                        sql("SELECT image_ref FROM pu_blob_view"
+                                        + " /*+ 
OPTIONS('blob-view.resolve.enabled'='false') */")
+                                .get(0)
+                                .getField(0);
+        byte[] forwardedReference =
+                (byte[])
+                        sql("SELECT image_ref FROM pu_blob_view_forward"
+                                        + " /*+ 
OPTIONS('blob-view.resolve.enabled'='false') */")
+                                .get(0)
+                                .getField(0);
+        assertThat(forwardedReference).isEqualTo(originalReference);
+        
assertThat(BlobViewStruct.deserialize(forwardedReference).identifier().getFullName())
+                .isEqualTo(fullTableName);
+    }
+
+    @Test
+    public void testBlobViewPartialUpdateSequenceGroup() throws Exception {
+        String fullTableName = createBlobViewUpstream();
+        sql(
+                "CREATE TABLE pu_blob_view_seq ("
+                        + " id INT PRIMARY KEY NOT ENFORCED,"
+                        + " label STRING,"
+                        + " image_ref BYTES,"
+                        + " ts INT"
+                        + ") WITH ("
+                        + " 'merge-engine'='partial-update',"
+                        + " 'blob-view-field'='image_ref',"
+                        + " 'fields.ts.sequence-group'='label,image_ref',"
+                        + " 'bucket'='1',"
+                        + " 'write-only'='true',"
+                        + " 'num-sorted-run.compaction-trigger'='100'"
+                        + ")");
+        sql(
+                String.format(
+                        "INSERT INTO pu_blob_view_seq"
+                                + " SELECT id, name, sys.blob_view('%s', 
'picture', _ROW_ID), 2"
+                                + " FROM `pu_upstream_blob$row_tracking`",
+                        fullTableName));
+
+        sql("INSERT INTO pu_blob_view_seq VALUES (1, 'older', CAST(NULL AS 
BYTES), 1)");
+        assertThat(sql("SELECT id, label, image_ref, ts FROM 
pu_blob_view_seq"))
+                .containsExactly(Row.of(1, "row1", new byte[] {72, 101, 108, 
108, 111}, 2));
+
+        sql("INSERT INTO pu_blob_view_seq VALUES (1, 'cleared', CAST(NULL AS 
BYTES), 3)");
+        assertThat(sql("SELECT id, label, image_ref, ts FROM 
pu_blob_view_seq"))
+                .containsExactly(Row.of(1, "cleared", null, 3));
+
+        tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+        sql(
+                "CALL sys.compact("
+                        + "`table` => 'default.pu_blob_view_seq', "
+                        + "compact_strategy => 'full')");
+        assertThat(sql("SELECT id, label, image_ref, ts FROM 
pu_blob_view_seq"))
+                .containsExactly(Row.of(1, "cleared", null, 3));
+    }
+
+    private String createBlobViewPartialUpdateTables() {
+        String fullTableName = createBlobViewUpstream();
+        sql(
+                "CREATE TABLE pu_blob_view ("
+                        + " id INT PRIMARY KEY NOT ENFORCED,"
+                        + " label STRING,"
+                        + " image_ref BYTES"
+                        + ") WITH ("
+                        + " 'merge-engine'='partial-update',"
+                        + " 'blob-view-field'='image_ref',"
+                        + " 'bucket'='1',"
+                        + " 'write-only'='true',"
+                        + " 'num-sorted-run.compaction-trigger'='100'"
+                        + ")");
+        sql(
+                String.format(
+                        "INSERT INTO pu_blob_view"
+                                + " SELECT id, name, sys.blob_view('%s', 
'picture', _ROW_ID)"
+                                + " FROM `pu_upstream_blob$row_tracking`",
+                        fullTableName));
+        return fullTableName;
+    }
+
+    private String createBlobViewUpstream() {
+        sql(
+                "CREATE TABLE pu_upstream_blob ("
+                        + " id INT, name STRING, picture BYTES"
+                        + ") WITH ("
+                        + " 'row-tracking.enabled'='true',"
+                        + " 'data-evolution.enabled'='true',"
+                        + " 'blob-field'='picture'"
+                        + ")");
+        sql("INSERT INTO pu_upstream_blob VALUES (1, 'row1', X'48656C6C6F')");
+
+        String fullTableName = tEnv.getCurrentDatabase() + ".pu_upstream_blob";
+        return fullTableName;
+    }
+
+    private static String toHexLiteral(byte[] data) {
+        StringBuilder builder = new StringBuilder("X'");
+        for (byte value : data) {
+            builder.append(String.format("%02X", value));
+        }
+        builder.append("'");
+        return builder.toString();
+    }
+
+    private String writeExternalBlob(String name, byte[] data) throws 
Exception {
+        FileIO fileIO = new LocalFileIO();
+        String uri = "file://" + path + "/" + name;
+        try (OutputStream outputStream =
+                fileIO.newOutputStream(new org.apache.paimon.fs.Path(uri), 
true)) {
+            outputStream.write(data);
+        }
+        return uri;
+    }
 }

Reply via email to