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 37c1bea125 [core][python] Fix partial ranges when updating newly
added Blob columns (#9388)
37c1bea125 is described below
commit 37c1bea1253809cd3b6eb600ad61a29a65977278
Author: zhoulii <[email protected]>
AuthorDate: Wed Aug 26 09:47:34 2026 +0800
[core][python] Fix partial ranges when updating newly added Blob columns
(#9388)
---
.../paimon/operation/BlobFallbackRecordReader.java | 51 +++++++++++--
.../paimon/operation/DataEvolutionSplitRead.java | 65 ++++++++++++-----
.../operation/BlobFallbackRecordReaderTest.java | 44 ++++++++++++
.../paimon/operation/DataEvolutionReadTest.java | 23 +++++-
.../pypaimon/read/reader/concat_batch_reader.py | 30 +++++---
paimon-python/pypaimon/read/reader/field_bunch.py | 58 +++++++++++----
paimon-python/pypaimon/read/split_read.py | 31 +++++---
paimon-python/pypaimon/tests/blob_table_test.py | 83 ++++++++++++++++++++++
paimon-python/pypaimon/tests/blob_test.py | 47 ++++++++++++
paimon-python/pypaimon/tests/field_bunch_test.py | 28 +++++++-
.../pypaimon/write/table_update_by_row_id.py | 62 ++++++++++------
11 files changed, 441 insertions(+), 81 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/BlobFallbackRecordReader.java
b/paimon-core/src/main/java/org/apache/paimon/operation/BlobFallbackRecordReader.java
index db501c1615..927933fce0 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/BlobFallbackRecordReader.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/BlobFallbackRecordReader.java
@@ -77,6 +77,25 @@ public class BlobFallbackRecordReader implements
RecordReader<InternalRow> {
RowType readRowType,
int blobIndex)
throws IOException {
+ this(
+ files,
+ readerFactory,
+ readerWrapper,
+ enclosingRange(files),
+ rowRanges,
+ readRowType,
+ blobIndex);
+ }
+
+ BlobFallbackRecordReader(
+ List<DataFileMeta> files,
+ BlobFileReaderFactory readerFactory,
+ ReaderWrapper readerWrapper,
+ Range logicalRange,
+ List<Range> rowRanges,
+ RowType readRowType,
+ int blobIndex)
+ throws IOException {
this.fieldCount = readRowType.getFieldCount();
this.rowIdIndex =
readRowType.getFieldIndex(SpecialFields.ROW_ID.name());
this.seqNumIndex =
readRowType.getFieldIndex(SpecialFields.SEQUENCE_NUMBER.name());
@@ -85,15 +104,27 @@ public class BlobFallbackRecordReader implements
RecordReader<InternalRow> {
InternalRow.createFieldGetter(readRowType.getTypeAt(blobIndex), blobIndex);
checkArgument(!files.isEmpty(), "Blob bunch should not be empty.");
- long firstRowId = Long.MAX_VALUE;
- long lastRowId = Long.MIN_VALUE;
+ long firstRowId = logicalRange.from;
+ long lastRowId = logicalRange.to;
// sort group readers in descending order
Map<Long, List<DataFileMeta>> sequenceGroups = new
TreeMap<>(reverseOrder());
for (DataFileMeta file : files) {
Range fileRange = file.nonNullRowIdRange();
- firstRowId = Math.min(firstRowId, fileRange.from);
- lastRowId = Math.max(lastRowId, fileRange.to);
+ if (rowRanges == null) {
+ checkArgument(
+ fileRange.from >= firstRowId && fileRange.to <=
lastRowId,
+ "Blob file range %s should be within logical range
%s.",
+ fileRange,
+ logicalRange);
+ } else {
+ // A pushed range may select one normal-file range from a
spanning Blob file.
+ checkArgument(
+ fileRange.hasIntersection(logicalRange),
+ "Blob file range %s should intersect logical range
%s.",
+ fileRange,
+ logicalRange);
+ }
sequenceGroups
.computeIfAbsent(file.maxSequenceNumber(), ignored -> new
ArrayList<>())
@@ -132,6 +163,18 @@ public class BlobFallbackRecordReader implements
RecordReader<InternalRow> {
}
}
+ private static Range enclosingRange(List<DataFileMeta> files) {
+ checkArgument(!files.isEmpty(), "Blob bunch should not be empty.");
+ long firstRowId = Long.MAX_VALUE;
+ long lastRowId = Long.MIN_VALUE;
+ for (DataFileMeta file : files) {
+ Range range = file.nonNullRowIdRange();
+ firstRowId = Math.min(firstRowId, range.from);
+ lastRowId = Math.max(lastRowId, range.to);
+ }
+ return new Range(firstRowId, lastRowId);
+ }
+
@Nullable
@Override
public RecordIterator<InternalRow> readBatch() throws IOException {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java
b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java
index e064736419..2a606ee677 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java
@@ -306,7 +306,7 @@ public class DataEvolutionSplitRead implements
SplitRead<InternalRow> {
rowRanges != null);
long rowCount = fieldsFiles.get(0).rowCount();
- long firstRowId =
fieldsFiles.get(0).files().get(0).nonNullFirstRowId();
+ long firstRowId = bunchFirstRowId(fieldsFiles.get(0));
if (rowRanges == null) {
for (FieldBunch bunch : fieldsFiles) {
@@ -314,7 +314,7 @@ public class DataEvolutionSplitRead implements
SplitRead<InternalRow> {
bunch.rowCount() == rowCount,
"All files in a field merge split should have the same
row count.");
checkArgument(
- bunch.files().get(0).nonNullFirstRowId() == firstRowId,
+ bunchFirstRowId(bunch) == firstRowId,
"All files in a field merge split should have the same
first row id and could not be null.");
}
}
@@ -437,7 +437,8 @@ public class DataEvolutionSplitRead implements
SplitRead<InternalRow> {
// for blob bunch, fallback on placeholders
// fast path: only contains one max_seq group
- if (((BlobFileBunch) bunch).sequentialReadOptimize()) {
+ BlobFileBunch blobBunch = (BlobFileBunch) bunch;
+ if (blobBunch.sequentialReadOptimize()) {
return sequentialReadFiles(
bunch.files(),
partition,
@@ -461,6 +462,7 @@ public class DataEvolutionSplitRead implements
SplitRead<InternalRow> {
deletionVector),
(reader, range) ->
applyDeletionVector(reader, range, rowRanges,
deletionVector),
+ blobBunch.logicalRange(),
rowRanges,
readRowType,
blobIndex);
@@ -1008,14 +1010,15 @@ public class DataEvolutionSplitRead implements
SplitRead<InternalRow> {
Map<Integer, BlobFileBunch> blobBunchMap = new HashMap<>();
Map<VectorStoreBunchKey, VectorFileBunch> vectorStoreBunchMap = new
TreeMap<>();
long rowCount = -1;
+ Range rowRange = null;
for (DataFileMeta file : needMergeFiles) {
if (isBlobFile(file.fileName())) {
RowType rowType = fileToRowType.apply(file);
int fieldId = rowType.getField(file.writeCols().get(0)).id();
- final long expectedRowCount = rowCount;
+ final Range expectedRowRange = rowRange;
blobBunchMap
.computeIfAbsent(
- fieldId, key -> new
BlobFileBunch(expectedRowCount, rowIdPushDown))
+ fieldId, key -> new
BlobFileBunch(expectedRowRange, rowIdPushDown))
.add(file);
} else if (isVectorStoreFile(file.fileName())) {
RowType rowType = fileToRowType.apply(file);
@@ -1033,6 +1036,7 @@ public class DataEvolutionSplitRead implements
SplitRead<InternalRow> {
// Normal file, just add it to the current merge split
fieldsFiles.add(new DataBunch(file));
rowCount = file.rowCount();
+ rowRange = file.nonNullRowIdRange();
}
}
fieldsFiles.addAll(blobBunchMap.values());
@@ -1048,6 +1052,13 @@ public class DataEvolutionSplitRead implements
SplitRead<InternalRow> {
List<DataFileMeta> files();
}
+ private static long bunchFirstRowId(FieldBunch bunch) {
+ if (bunch instanceof BlobFileBunch) {
+ return ((BlobFileBunch) bunch).logicalRange().from;
+ }
+ return bunch.files().get(0).nonNullFirstRowId();
+ }
+
private static class DataBunch implements FieldBunch {
private final DataFileMeta dataFile;
@@ -1076,12 +1087,14 @@ public class DataEvolutionSplitRead implements
SplitRead<InternalRow> {
final List<DataFileMeta> files;
final List<Range> ranges;
- final long expectedRowCount;
+ // The normal file owns the logical rows; a Blob column added later
may physically cover
+ // only a subset of that anchor range.
+ @Nullable final Range expectedRowRange;
final boolean rowIdPushdown;
- BlobFileBunch(long expectedRowCount, boolean rowIdPushdown) {
+ BlobFileBunch(@Nullable Range expectedRowRange, boolean rowIdPushdown)
{
this.files = new ArrayList<>();
- this.expectedRowCount = expectedRowCount;
+ this.expectedRowRange = expectedRowRange;
this.ranges = new ArrayList<>();
this.rowIdPushdown = rowIdPushdown;
}
@@ -1103,24 +1116,40 @@ public class DataEvolutionSplitRead implements
SplitRead<InternalRow> {
@Override
public long rowCount() {
List<Range> merged = Range.sortAndMergeOverlap(ranges, true);
+ if (expectedRowRange != null) {
+ for (Range range : merged) {
+ Preconditions.checkState(
+ range.from >= expectedRowRange.from && range.to <=
expectedRowRange.to,
+ "Blob file range %s should be within normal file
range %s.",
+ range,
+ expectedRowRange);
+ }
+ return expectedRowRange.count();
+ }
+
if (!rowIdPushdown) {
Preconditions.checkState(
merged.size() == 1,
"Blob file bunch should always contain a contiguous
row range.");
-
- long rowCount = merged.get(0).count();
- if (expectedRowCount >= 0) {
- Preconditions.checkState(
- rowCount == expectedRowCount,
- "The merged rowCount %s of blob file bunch should
be aligned with normal files %s.",
- rowCount,
- expectedRowCount);
- }
}
return merged.stream().mapToLong(Range::count).sum();
}
+ Range logicalRange() {
+ if (expectedRowRange != null) {
+ return expectedRowRange;
+ }
+ List<Range> merged = Range.sortAndMergeOverlap(ranges, true);
+ Preconditions.checkState(!merged.isEmpty(), "Blob file bunch
should not be empty.");
+ return new Range(merged.get(0).from, merged.get(merged.size() -
1).to);
+ }
+
+ private boolean fullyCoversLogicalRange() {
+ List<Range> merged = Range.sortAndMergeOverlap(ranges, true);
+ return merged.size() == 1 && merged.get(0).equals(logicalRange());
+ }
+
public boolean sequentialReadOptimize() {
Preconditions.checkState(!files.isEmpty(), "Blob file bunch should
not be empty.");
@@ -1133,7 +1162,7 @@ public class DataEvolutionSplitRead implements
SplitRead<InternalRow> {
}
}
- return true;
+ return fullyCoversLogicalRange();
}
@Override
diff --git
a/paimon-core/src/test/java/org/apache/paimon/operation/BlobFallbackRecordReaderTest.java
b/paimon-core/src/test/java/org/apache/paimon/operation/BlobFallbackRecordReaderTest.java
index 859e8bc5b8..328aaa3061 100644
---
a/paimon-core/src/test/java/org/apache/paimon/operation/BlobFallbackRecordReaderTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/operation/BlobFallbackRecordReaderTest.java
@@ -313,6 +313,50 @@ public class BlobFallbackRecordReaderTest {
assertThat(rows.sequenceNumbers).containsExactly(1L, 1L, 1L, 2L, 1L,
1L);
}
+ @Test
+ public void testBlobFallbackRecordReaderFillsLogicalRangeGapsWithNull()
throws Exception {
+ DataFileMeta file = blobFile("partial-file", 2, 2, 1);
+
+ ReadResult rows =
+ ReadResult.read(
+ new BlobFallbackRecordReader(
+ Collections.singletonList(file),
+ blobFile ->
+ oneRowPerBatchReader(blobFile,
fileRows(blobFile, null)),
+ (reader, range) -> reader,
+ new Range(0, 5),
+ null,
+ READ_ROW_TYPE,
+ BLOB_INDEX));
+
+ assertThat(rows.rowIds).containsExactly(2L, 3L);
+ assertThat(rows.nullBlobRowIds).containsExactly(0L, 1L, 4L, 5L);
+ assertThat(rows.nullBlobSequenceNumbers).containsOnly(-1L);
+ }
+
+ @Test
+ public void testBlobFallbackRecordReaderClipsSpanningFileToLogicalRange()
throws Exception {
+ DataFileMeta file = blobFile("spanning-file", 5, 10, 1);
+ List<Range> rowRanges = Collections.singletonList(new Range(5, 9));
+
+ ReadResult rows =
+ ReadResult.read(
+ new BlobFallbackRecordReader(
+ Collections.singletonList(file),
+ blobFile ->
+ oneRowPerBatchReader(
+ blobFile, fileRows(blobFile,
rowRanges)),
+ (reader, range) -> reader,
+ new Range(5, 9),
+ rowRanges,
+ READ_ROW_TYPE,
+ BLOB_INDEX));
+
+ assertThat(rows.rowIds).containsExactly(5L, 6L, 7L, 8L, 9L);
+ assertThat(rows.sequenceNumbers).containsOnly(1L);
+ assertThat(rows.nullBlobRowIds).isEmpty();
+ }
+
@Test
public void
testBlobFallbackRecordReaderReturnsNullIfAllRowsArePlaceholders() throws
Exception {
DataFileMeta newFile = blobFile("new-placeholder-file", 0, 1, 2);
diff --git
a/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionReadTest.java
b/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionReadTest.java
index 24d2994957..bdb9fcc935 100644
---
a/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionReadTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionReadTest.java
@@ -28,6 +28,7 @@ import org.apache.paimon.stats.SimpleStats;
import org.apache.paimon.types.DataField;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.Range;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -464,7 +465,7 @@ public class DataEvolutionReadTest {
@Test
void testAddBlobFilesWithDifferentSchemaId() {
- BlobFileBunch blobBunch = new BlobFileBunch(300, false);
+ BlobFileBunch blobBunch = new BlobFileBunch(new Range(0, 299), false);
DataFileMeta blobEntry1 = createBlobFileWithSchema("blob1", 0, 100, 1,
0L);
DataFileMeta blobEntry2 = createBlobFileWithSchema("blob2", 100, 200,
1, 1L);
@@ -477,6 +478,26 @@ public class DataEvolutionReadTest {
assertThat(blobBunch.rowCount()).isEqualTo(300);
}
+ @Test
+ void testBlobBunchUsesNormalFileRangeForPartialCoverage() {
+ BlobFileBunch blobBunch = new BlobFileBunch(new Range(100, 109),
false);
+ blobBunch.add(createBlobFile("blob", 103, 5, 1));
+
+ assertThat(blobBunch.rowCount()).isEqualTo(10);
+ assertThat(blobBunch.logicalRange()).isEqualTo(new Range(100, 109));
+ assertThat(blobBunch.sequentialReadOptimize()).isFalse();
+ }
+
+ @Test
+ void testBlobBunchRejectsRangeOutsideNormalFile() {
+ BlobFileBunch blobBunch = new BlobFileBunch(new Range(100, 109),
false);
+ blobBunch.add(createBlobFile("blob", 99, 2, 1));
+
+ assertThatThrownBy(blobBunch::rowCount)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("should be within normal file range");
+ }
+
@Test
public void testRowIdPushDown() {
VectorFileBunch vectorBunch = new VectorFileBunch(Long.MAX_VALUE,
true);
diff --git a/paimon-python/pypaimon/read/reader/concat_batch_reader.py
b/paimon-python/pypaimon/read/reader/concat_batch_reader.py
index 831e41012a..39b7dac1d6 100644
--- a/paimon-python/pypaimon/read/reader/concat_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/concat_batch_reader.py
@@ -294,11 +294,16 @@ class BlobFallbackBatchReader(RecordBatchReader):
def __init__(self, file_reader_suppliers: List[Tuple[DataFileMeta,
Callable]],
field_name: str, output_type, row_ranges:
Optional[List[Range]] = None,
blob_as_descriptor: bool = False, deletion_vector=None,
batch_size: int = 1024,
- blob_parallelism: int = 1):
+ blob_parallelism: int = 1,
+ logical_ranges: Optional[List[Range]] = None):
self._file_reader_suppliers = file_reader_suppliers
self._field_name = field_name
self._output_type = output_type
self._row_ranges = Range.sort_and_merge_overlap(row_ranges) if
row_ranges else None
+ self._logical_ranges = (
+ Range.sort_and_merge_overlap(logical_ranges)
+ if logical_ranges is not None else None
+ )
self._blob_as_descriptor = blob_as_descriptor
self._is_array_blob = pa.types.is_list(output_type) or
pa.types.is_large_list(output_type)
self._is_map_blob = pa.types.is_map(output_type)
@@ -386,14 +391,15 @@ class BlobFallbackBatchReader(RecordBatchReader):
if state.selected_range_index >= len(state.selected_ranges):
self._close_state_reader(state)
- if not groups:
- return None
-
+ groups_by_sequence = [
+ groups[sequence]
+ for sequence in sorted(groups.keys(), reverse=True)
+ ]
result = []
for row_id in batch_row_ids:
found = False
- for max_sequence_number in sorted(groups.keys(), reverse=True):
- candidate = groups[max_sequence_number].get(row_id)
+ for group in groups_by_sequence:
+ candidate = group.get(row_id)
if candidate is None:
continue
value, is_placeholder = candidate
@@ -402,7 +408,7 @@ class BlobFallbackBatchReader(RecordBatchReader):
found = True
break
if not found:
- raise ValueError("All blob files at the same row id store a
placeholder.")
+ result.append(None)
if resolve_blobs_concurrently:
result = self._resolve_selected_blobs(result)
@@ -492,10 +498,12 @@ class BlobFallbackBatchReader(RecordBatchReader):
return resolved
def _compute_target_ranges(self) -> List[Range]:
- ranges = Range.sort_and_merge_overlap([
- file.row_id_range()
- for file, _ in self._file_reader_suppliers
- ])
+ ranges = self._logical_ranges
+ if ranges is None:
+ ranges = Range.sort_and_merge_overlap([
+ file.row_id_range()
+ for file, _ in self._file_reader_suppliers
+ ])
if self._row_ranges is not None:
ranges = Range.and_(ranges, self._row_ranges)
return ranges
diff --git a/paimon-python/pypaimon/read/reader/field_bunch.py
b/paimon-python/pypaimon/read/reader/field_bunch.py
index 5474bf855a..65ab1af046 100644
--- a/paimon-python/pypaimon/read/reader/field_bunch.py
+++ b/paimon-python/pypaimon/read/reader/field_bunch.py
@@ -22,7 +22,7 @@ These classes help organize DataFileMeta objects into groups
based on their fiel
supporting both regular data files and blob files.
"""
from abc import ABC
-from typing import List
+from typing import List, Optional
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.utils.range import Range
@@ -144,8 +144,13 @@ class _SpecialFieldBunch(FieldBunch):
class BlobBunch(_SpecialFieldBunch):
"""Files for partial field (blob files)."""
- def __init__(self, expected_row_count: int, row_id_push_down: bool =
False):
+ def __init__(self, expected_row_count: int, row_id_push_down: bool = False,
+ expected_row_range: Optional[Range] = None):
super().__init__(expected_row_count, row_id_push_down)
+ # The normal file is the logical anchor. A Blob column added later may
+ # physically cover only part of its row-id range.
+ self.expected_row_range = expected_row_range
+ self._merged_ranges: List[Range] = []
self._finished = False
def add(self, file: DataFileMeta) -> None:
@@ -162,18 +167,25 @@ class BlobBunch(_SpecialFieldBunch):
if self._finished:
return
- merged = Range.sort_and_merge_overlap(
- [blob_file.row_id_range() for blob_file in self._files],
- True,
- True,
- )
- self._row_count = sum(row_range.count() for row_range in merged)
- if self.expected_row_count >= 0 and self._row_count >
self.expected_row_count:
- raise ValueError(
- f"Blob files row count exceed the expect
{self.expected_row_count}"
- )
+ merged = self._merged_physical_ranges()
+ physical_row_count = sum(row_range.count() for row_range in merged)
+ if self.expected_row_range is not None:
+ for row_range in merged:
+ if (row_range.from_ < self.expected_row_range.from_
+ or row_range.to > self.expected_row_range.to):
+ raise ValueError(
+ f"Blob file range {row_range} should be within normal "
+ f"file range {self.expected_row_range}."
+ )
+ self._row_count = self.expected_row_range.count()
+ else:
+ self._row_count = physical_row_count
+ if self.expected_row_count >= 0 and self._row_count >
self.expected_row_count:
+ raise ValueError(
+ f"Blob files row count exceed the expect
{self.expected_row_count}"
+ )
- if not self.row_id_push_down:
+ if not self.row_id_push_down and self.expected_row_range is None:
if len(merged) != 1:
raise ValueError("Blob file bunch should always contain a
contiguous row range.")
if self.expected_row_count >= 0 and self._row_count !=
self.expected_row_count:
@@ -181,20 +193,38 @@ class BlobBunch(_SpecialFieldBunch):
"The merged row count of blob file bunch should be aligned
"
f"with normal files, expect {self.expected_row_count}, got
{self._row_count}."
)
+ self._merged_ranges = merged
self._finished = True
def row_count(self) -> int:
self.finish()
return self._row_count
+ def logical_range(self) -> Range:
+ if self.expected_row_range is not None:
+ return self.expected_row_range
+ merged = self._merged_physical_ranges()
+ if not merged:
+ raise ValueError("Blob bunch should not be empty.")
+ return Range(merged[0].from_, merged[-1].to)
+
def sequential_read_optimize(self) -> bool:
if not self._files:
raise ValueError("Blob bunch should not be empty.")
max_sequence_number = self._files[0].max_sequence_number
- return all(
+ same_sequence = all(
file.max_sequence_number == max_sequence_number
for file in self._files
)
+ merged = self._merged_physical_ranges()
+ return (same_sequence and len(merged) == 1
+ and merged[0] == self.logical_range())
+
+ def _merged_physical_ranges(self) -> List[Range]:
+ if self._finished:
+ return self._merged_ranges
+ return Range.sort_and_merge_overlap(
+ [blob_file.row_id_range() for blob_file in self._files], True,
True)
def _is_special_file(self, file_name: str) -> bool:
return DataFileMeta.is_blob_file(file_name)
diff --git a/paimon-python/pypaimon/read/split_read.py
b/paimon-python/pypaimon/read/split_read.py
index b647e713c4..cc0cca45c9 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -1319,14 +1319,14 @@ class DataEvolutionSplitRead(SplitRead):
# Validate row counts and first row IDs (skip when row ranges are
pushed down)
row_count = fields_files[0].row_count()
- first_row_id = fields_files[0].files()[0].first_row_id
+ first_row_id = self._bunch_first_row_id(fields_files[0])
if self.row_ranges is None:
for bunch in fields_files:
if bunch.row_count() != row_count:
raise ValueError(
"All files in a field merge split should have the same
row count.")
- if bunch.files()[0].first_row_id != first_row_id:
+ if self._bunch_first_row_id(bunch) != first_row_id:
raise ValueError(
"All files in a field merge split should have the same
"
"first row id and could not be null."
@@ -1387,12 +1387,10 @@ class DataEvolutionSplitRead(SplitRead):
# non-empty bunch reader created below must return the same
row-id
# sequence. Keep row_ranges and the group-level deletion vector
# applied uniformly across normal, blob, and vector bunches.
- if len(bunch.files()) == 1:
- suppliers = [lambda r=self._create_file_reader(
- bunch.files()[0], read_field_names, deletion_vector
- ): r]
- file_record_readers[i] = MergeAllBatchReader(suppliers,
batch_size=batch_size)
- elif DataFileMeta.is_blob_file(first_file.file_name):
+ is_blob_bunch = isinstance(bunch, BlobBunch)
+ if (is_blob_bunch
+ and (len(bunch.files()) != 1
+ or not bunch.sequential_read_optimize())):
file_reader_suppliers = [
(
file,
@@ -1415,7 +1413,13 @@ class DataEvolutionSplitRead(SplitRead):
deletion_vector=deletion_vector,
batch_size=batch_size,
blob_parallelism=self._blob_parallelism,
+ logical_ranges=[bunch.logical_range()],
)
+ elif len(bunch.files()) == 1:
+ suppliers = [lambda r=self._create_file_reader(
+ bunch.files()[0], read_field_names, deletion_vector
+ ): r]
+ file_record_readers[i] = MergeAllBatchReader(suppliers,
batch_size=batch_size)
else:
# Create concatenated reader for multiple files
suppliers = [
@@ -1480,13 +1484,15 @@ class DataEvolutionSplitRead(SplitRead):
blob_bunch_map = {}
vector_bunch_map = {}
row_count = -1
+ row_range = None
row_id_push_down = self.row_ranges is not None
for file in need_merge_files:
if DataFileMeta.is_blob_file(file.file_name):
field_id = self._get_field_id_from_write_cols(file)
if field_id not in blob_bunch_map:
- blob_bunch_map[field_id] = BlobBunch(row_count,
row_id_push_down)
+ blob_bunch_map[field_id] = BlobBunch(
+ row_count, row_id_push_down, row_range)
blob_bunch_map[field_id].add(file)
elif DataFileMeta.is_vector_file(file.file_name):
field_id = self._get_field_id_from_write_cols(file)
@@ -1496,6 +1502,7 @@ class DataEvolutionSplitRead(SplitRead):
else:
fields_files.append(DataBunch(file))
row_count = file.row_count
+ row_range = file.row_id_range()
for bunch in blob_bunch_map.values():
bunch.finish()
@@ -1503,6 +1510,12 @@ class DataEvolutionSplitRead(SplitRead):
fields_files.extend(vector_bunch_map.values())
return fields_files
+ @staticmethod
+ def _bunch_first_row_id(bunch: FieldBunch) -> int:
+ if isinstance(bunch, BlobBunch):
+ return bunch.logical_range().from_
+ return bunch.files()[0].first_row_id
+
def _get_field_id_from_write_cols(self, file: DataFileMeta) -> int:
"""Get field ID from write columns for blob/vector files."""
if not file.write_cols or len(file.write_cols) == 0:
diff --git a/paimon-python/pypaimon/tests/blob_table_test.py
b/paimon-python/pypaimon/tests/blob_table_test.py
index 4b7e99a937..c08289b4ef 100755
--- a/paimon-python/pypaimon/tests/blob_table_test.py
+++ b/paimon-python/pypaimon/tests/blob_table_test.py
@@ -1779,6 +1779,89 @@ class DedicatedFormatWriterTest(unittest.TestCase):
self.assertEqual(by_id[0], b'updated-0')
self.assertEqual(by_id[9], b'blob-9')
+ def test_update_new_blob_column_writes_full_normal_range(self):
+ from pypaimon import Schema
+ from pypaimon.read.reader.format_blob_reader import FormatBlobReader
+ from pypaimon.schema.data_types import AtomicType
+ from pypaimon.write.blob_format_writer import BlobFormatWriter
+
+ table_name = 'test_db.blob_update_new_column'
+ normal_schema = pa.schema([
+ ('id', pa.int32()),
+ ('name', pa.string()),
+ ])
+ schema = Schema.from_pyarrow_schema(normal_schema, options={
+ 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true',
+ })
+ self.catalog.create_table(table_name, schema, False)
+ table = self.catalog.get_table(table_name)
+
+ write_builder = table.new_batch_write_builder()
+ writer = write_builder.new_write()
+ writer.write_arrow(pa.Table.from_pydict({
+ 'id': [1, 2, 3],
+ 'name': ['a', 'b', 'c'],
+ }, schema=normal_schema))
+ write_builder.new_commit().commit(writer.prepare_commit())
+ writer.close()
+
+ self.catalog.alter_table(
+ table_name,
+ [SchemaChange.add_column('blob_data', AtomicType('BLOB'))],
+ False,
+ )
+ table = self.catalog.get_table(table_name)
+ row_id_builder = table.new_read_builder().with_projection(
+ ['id', '_ROW_ID'])
+ row_id_result = row_id_builder.new_read().to_arrow(
+ row_id_builder.new_scan().plan().splits()).sort_by('id')
+ first_row_id = row_id_result.column('_ROW_ID')[0].as_py()
+
+ update_builder = table.new_batch_write_builder()
+ table_update = update_builder.new_update().with_update_type(
+ ['blob_data'])
+ update_data = pa.Table.from_pydict({
+ '_ROW_ID': pa.array([first_row_id], type=pa.int64()),
+ 'blob_data': pa.array([b'updated-blob'], type=pa.large_binary()),
+ })
+ update_messages = table_update.update_by_arrow_with_row_id(update_data)
+ update_blob_files = [
+ file
+ for message in update_messages
+ for file in message.new_files
+ if file.file_name.endswith('.blob')
+ ]
+ self.assertEqual(len(update_blob_files), 1)
+ self.assertEqual(update_blob_files[0].first_row_id, first_row_id)
+ self.assertEqual(update_blob_files[0].row_count, 3)
+
+ blob_reader = FormatBlobReader(
+ file_io=table.file_io,
+ file_path=update_blob_files[0].file_path,
+ read_fields=['blob_data'],
+ full_fields=[table.field_dict['blob_data']],
+ push_down_predicate=None,
+ blob_as_descriptor=False,
+ )
+ blob_lengths = list(blob_reader.blob_lengths)
+ blob_reader.close()
+ self.assertEqual(blob_lengths.count(BlobFormatWriter.NULL_LENGTH), 2)
+ self.assertNotIn(BlobFormatWriter.PLACE_HOLDER_LENGTH, blob_lengths)
+
+ update_builder.new_commit().commit(update_messages)
+ read_builder = table.new_read_builder()
+ result = read_builder.new_read().to_arrow(
+ read_builder.new_scan().plan().splits()).sort_by('id')
+ self.assertEqual(
+ result.select(['id', 'blob_data']).to_pylist(),
+ [
+ {'id': 1, 'blob_data': b'updated-blob'},
+ {'id': 2, 'blob_data': None},
+ {'id': 3, 'blob_data': None},
+ ],
+ )
+
def test_blob_update_all_rows_full_span(self):
from pypaimon import Schema
diff --git a/paimon-python/pypaimon/tests/blob_test.py
b/paimon-python/pypaimon/tests/blob_test.py
index a20f24dcfe..4c6a7b1932 100644
--- a/paimon-python/pypaimon/tests/blob_test.py
+++ b/paimon-python/pypaimon/tests/blob_test.py
@@ -44,6 +44,7 @@ from pypaimon.schema.data_types import ArrayType, AtomicType,
DataField, MapType
from pypaimon.table.row.blob import Blob, BlobData, BlobRef, BlobDescriptor,
BlobViewStruct, BlobView
from pypaimon.table.row.generic_row import GenericRowDeserializer,
GenericRowSerializer, GenericRow
from pypaimon.table.row.row_kind import RowKind
+from pypaimon.utils.range import Range
class MockFileIO:
@@ -580,6 +581,52 @@ class BlobTest(unittest.TestCase):
reader.close()
self.assertTrue(created_by_file["third.blob"][0].closed)
+ def
test_blob_fallback_batch_reader_fills_logical_range_gaps_with_null(self):
+ class FakeBlobReader:
+ def __init__(self):
+ self._file_io = None
+ self.file_path = "partial.blob"
+ self.blob_lengths = [20]
+ self.blob_offsets = [0]
+ self._input_stream = None
+
+ def close(self):
+ pass
+
+ file = DataFileMeta(
+ file_name="partial.blob",
+ file_size=0,
+ row_count=1,
+ min_key=None,
+ max_key=None,
+ key_stats=None,
+ value_stats=None,
+ min_sequence_number=1,
+ max_sequence_number=1,
+ schema_id=0,
+ level=0,
+ extra_files=[],
+ first_row_id=2,
+ file_path="partial.blob",
+ )
+ reader = BlobFallbackBatchReader(
+ [(file, FakeBlobReader)],
+ "picture",
+ pa.large_binary(),
+ blob_as_descriptor=True,
+ logical_ranges=[Range(0, 4)],
+ )
+
+ batch = reader.read_arrow_batch()
+
+ values = batch.column("picture").to_pylist()
+ self.assertIsNone(values[0])
+ self.assertIsNone(values[1])
+ self.assertEqual(4, BlobDescriptor.deserialize(values[2]).offset)
+ self.assertIsNone(values[3])
+ self.assertIsNone(values[4])
+ self.assertIsNone(reader.read_arrow_batch())
+
def
test_blob_fallback_batch_reader_materializes_selected_values_in_parallel(self):
class RecordingFileIO:
def __init__(self):
diff --git a/paimon-python/pypaimon/tests/field_bunch_test.py
b/paimon-python/pypaimon/tests/field_bunch_test.py
index 0b6772d284..6cc4363e4d 100644
--- a/paimon-python/pypaimon/tests/field_bunch_test.py
+++ b/paimon-python/pypaimon/tests/field_bunch_test.py
@@ -28,11 +28,13 @@ class _BlobFile:
file_name: str,
first_row_id: int,
row_count: int,
- write_cols=None):
+ write_cols=None,
+ max_sequence_number: int = 0):
self.file_name = file_name
self.first_row_id = first_row_id
self.row_count = row_count
self.write_cols = write_cols or ["blob_col"]
+ self.max_sequence_number = max_sequence_number
def row_id_range(self) -> Range:
return Range(
@@ -58,6 +60,8 @@ class BlobBunchTest(unittest.TestCase):
bunch.finish()
self.assertEqual(1, mocked_merge.call_count)
self.assertEqual(1000, bunch.row_count())
+ self.assertEqual(Range(0, 999), bunch.logical_range())
+ self.assertTrue(bunch.sequential_read_optimize())
self.assertEqual(1, mocked_merge.call_count)
def test_finish_preserves_overlapping_versions(self):
@@ -125,6 +129,28 @@ class BlobBunchTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "row count exceed"):
bunch.finish()
+ def test_finish_uses_normal_range_for_partial_blob_coverage(self):
+ bunch = BlobBunch(
+ expected_row_count=10,
+ expected_row_range=Range(100, 109),
+ )
+ bunch.add(_BlobFile("partial.blob", 103, 5))
+
+ bunch.finish()
+
+ self.assertEqual(10, bunch.row_count())
+ self.assertEqual(Range(100, 109), bunch.logical_range())
+
+ def test_finish_rejects_range_outside_normal_file(self):
+ bunch = BlobBunch(
+ expected_row_count=10,
+ expected_row_range=Range(100, 109),
+ )
+ bunch.add(_BlobFile("outside.blob", 99, 2))
+
+ with self.assertRaisesRegex(ValueError, "within normal file range"):
+ bunch.finish()
+
if __name__ == "__main__":
unittest.main()
diff --git a/paimon-python/pypaimon/write/table_update_by_row_id.py
b/paimon-python/pypaimon/write/table_update_by_row_id.py
index 0a68c682e2..943ca29b79 100644
--- a/paimon-python/pypaimon/write/table_update_by_row_id.py
+++ b/paimon-python/pypaimon/write/table_update_by_row_id.py
@@ -17,7 +17,7 @@
import bisect
from dataclasses import dataclass, field
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any, Dict, List, Optional, Set, Tuple
import numpy as np
import pyarrow as pa
@@ -424,30 +424,32 @@ class TableUpdateByRowId:
column_names: List[str],
first_row_id: int,
blob_object_columns: Optional[Dict[str, List[Any]]] = None,
+ blob_columns_with_baseline: Optional[Set[str]] = None,
) -> Tuple[Optional[pa.Table], Dict[str, List[object]]]:
"""Merge update data with original data, preserving row order.
For rows that have updates, use the update values.
For rows without updates, use the original values (if available).
- Blob delta files cover ``[first_row_id, first_row_id +
max_updated_pos]``
- — anchored at the original file's first_row_id, spanning up to and
- including the last updated row. The span is NOT shrunk at the head:
- ``BlobFallbackRecordReader`` resolves placeholders by relative offset
- from the delta file's ``first_row_id``, so anchoring anywhere other
- than the original ``first_row_id`` would misalign unchanged rows
- before ``min_updated_pos`` with the older blob file. This anchor is
- the same in every blob column being updated.
+ Blob delta files are anchored at the original file's first_row_id.
+ Existing Blob columns only need to span through their last updated
+ row because placeholders can fall back to older files. A Blob column
+ without a physical baseline spans the complete normal file and writes
+ real nulls for unchanged rows, so it does not create placeholders with
+ nowhere to fall back.
Args:
original_data: Original data from the file (may be None if no
columns need to be read)
update_data: Update data (may contain only partial rows)
column_names: Column names being updated
first_row_id: The first_row_id of this file group
+ blob_object_columns: Blob objects supplied by row-based updates
+ blob_columns_with_baseline: Blob columns backed by an existing
physical file
Returns:
- Normal merged PyArrow Table, and per-blob-column values list. All
- blob value lists have the same length (= ``max_updated_pos + 1``).
+ Normal merged PyArrow Table, and per-blob-column values list.
+ Blob value list lengths may differ when the updated columns do not
+ all have physical baselines.
"""
# Get the _ROW_ID values from update_data to determine which rows are
being updated
@@ -471,25 +473,32 @@ class TableUpdateByRowId:
sorted_updates = None
# Caller (_write_by_first_row_id) only enters this method with a
# non-empty group, so update_positions is non-empty here.
- blob_row_count = max(update_positions) + 1
+ blob_columns_with_baseline = blob_columns_with_baseline or set()
for col_name in column_names:
if self._is_blob_column(col_name):
+ has_baseline = col_name in blob_columns_with_baseline
+ blob_row_count = (
+ max(update_positions) + 1
+ if has_baseline else original_data.num_rows
+ )
+ missing_value = (
+ self._blob_placeholder(col_name)
+ if has_baseline else None
+ )
if blob_object_columns and col_name in blob_object_columns:
update_values = blob_object_columns[col_name]
- placeholder = self._blob_placeholder(col_name)
blob_columns[col_name] = [
update_values[update_positions[i]]
if i in update_positions
- else placeholder
+ else missing_value
for i in range(blob_row_count)
]
continue
update_col = update_by_col[col_name]
- placeholder = self._blob_placeholder(col_name)
blob_columns[col_name] = [
update_col[update_positions[i]].as_py()
if i in update_positions
- else placeholder
+ else missing_value
for i in range(blob_row_count)
]
continue
@@ -744,12 +753,20 @@ class TableUpdateByRowId:
writes a single output file (rolling disabled) for the group.
"""
original_data = self._read_original_file_data(first_row_id,
column_names)
+ _, target_files = self._first_row_id_index[first_row_id]
+ blob_columns_with_baseline = {
+ column_name
+ for file in target_files
+ if DataFileMeta.is_blob_file(file.file_name)
+ for column_name in (file.write_cols or [])
+ }
merged_data, blob_columns = self._merge_update_with_original(
original_data,
data,
column_names,
first_row_id,
blob_object_columns,
+ blob_columns_with_baseline,
)
partition_tuple = tuple(partition.values)
@@ -811,13 +828,10 @@ class TableUpdateByRowId:
def _assign_update_file_metadata(new_files: List[DataFileMeta],
first_row_id: int,
column_names: List[str],
blob_columns: Dict[str, List[object]]):
- # All blob columns share the same anchored span (see
- # _merge_update_with_original docstring), so any column's length is
- # the per-blob delta-file row count.
- blob_row_count = (
- len(next(iter(blob_columns.values()))) if blob_columns else 0
- )
- blob_end = first_row_id + blob_row_count
+ blob_ends = {
+ column_name: first_row_id + len(values)
+ for column_name, values in blob_columns.items()
+ }
blob_starts = {}
# BlobWriter.prepare_commit preserves write/rolling order, which is
required
# for assigning continuous row-id ranges to rolled blob files.
@@ -831,6 +845,7 @@ class TableUpdateByRowId:
blob_column = file.write_cols[0]
blob_start = blob_starts.get(blob_column, first_row_id)
next_blob_start = blob_start + file.row_count
+ blob_end = blob_ends[blob_column]
if next_blob_start > blob_end:
raise RuntimeError(
f"Blob update file {file.file_name} row-id range "
@@ -846,6 +861,7 @@ class TableUpdateByRowId:
file.first_row_id = first_row_id
for blob_column, next_blob_start in blob_starts.items():
+ blob_end = blob_ends[blob_column]
if next_blob_start != blob_end:
raise RuntimeError(
f"Blob update column {blob_column} covers row ids "