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 b31acd09d6 [python] supports reading data evolution tables with
deletionVectorEnabled (#8404)
b31acd09d6 is described below
commit b31acd09d6a14a4773350c13cc24c920012f9cf2
Author: Faiz <[email protected]>
AuthorDate: Wed Jul 1 22:45:45 2026 +0800
[python] supports reading data evolution tables with deletionVectorEnabled
(#8404)
---
.../test/java/org/apache/paimon/JavaPyE2ETest.java | 132 ++++++++++
paimon-python/dev/run_mixed_tests.sh | 40 ++-
paimon-python/pypaimon/deletionvectors/__init__.py | 9 +-
.../apply_deletion_vector_reader.py | 86 +++++--
.../pypaimon/read/reader/concat_batch_reader.py | 26 +-
.../pypaimon/read/scanner/file_scanner.py | 2 +
paimon-python/pypaimon/read/split.py | 13 +-
paimon-python/pypaimon/read/split_read.py | 77 +++++-
.../tests/data_evolution_deletion_vector_test.py | 270 +++++++++++++++++++++
.../pypaimon/tests/e2e/java_py_read_write_test.py | 22 ++
.../pypaimon/tests/reader_split_generator_test.py | 19 +-
.../pypaimon/utils/data_evolution_utils.py | 51 ++++
12 files changed, 710 insertions(+), 37 deletions(-)
diff --git a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
index 695cc29d76..8579fb9e85 100644
--- a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
@@ -24,19 +24,29 @@ import org.apache.paimon.catalog.Catalog;
import org.apache.paimon.catalog.CatalogContext;
import org.apache.paimon.catalog.CatalogFactory;
import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.data.BinaryString;
import org.apache.paimon.data.BinaryVector;
+import org.apache.paimon.data.BlobData;
import org.apache.paimon.data.DataFormatTestUtil;
import org.apache.paimon.data.GenericRow;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.data.variant.GenericVariant;
+import org.apache.paimon.deletionvectors.BitmapDeletionVector;
+import org.apache.paimon.deletionvectors.DeletionVector;
+import org.apache.paimon.deletionvectors.append.BaseAppendDeleteFileMaintainer;
import org.apache.paimon.disk.IOManager;
import org.apache.paimon.fs.FileIOFinder;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.local.LocalFileIO;
import org.apache.paimon.globalindex.sorted.SortedGlobalIndexBuilder;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.CompactIncrement;
import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.manifest.FileKind;
import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.ManifestEntry;
import org.apache.paimon.options.MemorySize;
import org.apache.paimon.options.Options;
import org.apache.paimon.predicate.Predicate;
@@ -66,6 +76,8 @@ import org.apache.paimon.types.DataType;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.RowKind;
import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RangeHelper;
import org.apache.paimon.utils.TraceableFileIO;
import org.junit.jupiter.api.BeforeEach;
@@ -79,7 +91,9 @@ import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Function;
@@ -95,7 +109,9 @@ import static org.apache.paimon.CoreOptions.TARGET_FILE_SIZE;
import static org.apache.paimon.data.DataFormatTestUtil.internalRowToString;
import static
org.apache.paimon.globalindex.bitmap.BitmapGlobalIndexOptions.BITMAP_INDEX_COMPRESSION;
import static
org.apache.paimon.globalindex.btree.BTreeIndexOptions.BTREE_INDEX_COMPRESSION;
+import static org.apache.paimon.table.BucketMode.UNAWARE_BUCKET;
import static org.apache.paimon.table.SimpleTableTestBase.getResult;
+import static org.apache.paimon.utils.DataEvolutionUtils.retrieveAnchorFile;
import static org.assertj.core.api.Assertions.assertThat;
/** Mixed language overwrite test for Java and Python interoperability. */
@@ -1656,6 +1672,53 @@ public class JavaPyE2ETest {
}
}
+ @Test
+ @EnabledIfSystemProperty(named = "run.e2e.tests", matches = "true")
+ public void testDataEvolutionDeletionVectorWrite() throws Exception {
+ Identifier identifier = identifier("data_evolution_dv_test");
+ catalog.dropTable(identifier, true);
+ Schema schema =
+ Schema.newBuilder()
+ .column("f0", DataTypes.INT())
+ .column("f1", DataTypes.STRING())
+ .column("f2", DataTypes.STRING())
+ .column("f3", DataTypes.BLOB())
+ .option(CoreOptions.FILE_FORMAT.key(), "parquet")
+ .option(TARGET_FILE_SIZE.key(), "128 MB")
+ .option(CoreOptions.BLOB_TARGET_FILE_SIZE.key(), "1 b")
+ .option(ROW_TRACKING_ENABLED.key(), "true")
+ .option(DATA_EVOLUTION_ENABLED.key(), "true")
+ .option(DELETION_VECTORS_ENABLED.key(), "true")
+ .build();
+ catalog.createTable(identifier, schema, false);
+
+ FileStoreTable table = (FileStoreTable) catalog.getTable(identifier);
+ for (int batch = 0; batch < 3; batch++) {
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = builder.newWrite();
+ BatchTableCommit commit = builder.newCommit()) {
+ for (int rowId = batch * 5; rowId < batch * 5 + 5; rowId++) {
+ write.write(
+ GenericRow.of(
+ rowId,
+ BinaryString.fromString("name-" + rowId),
+ BinaryString.fromString("base-" + rowId),
+ new BlobData(new byte[] {(byte) rowId})));
+ }
+ commit.commit(write.prepareCommit());
+ }
+ }
+
+ commitDeletionVectors(
+ table,
+ Arrays.asList(
+ new E2eDvSpec(new Range(0, 4), 1, 4),
+ new E2eDvSpec(new Range(5, 9), 5, 6, 7, 8, 9),
+ new E2eDvSpec(new Range(10, 14), 10, 12)));
+
+ LOG.info("data_evolution_dv_test: written 15 rows with deletion
vectors");
+ }
+
/** Read data evolution tables written by Python. */
@Test
@EnabledIfSystemProperty(named = "run.e2e.tests", matches = "true")
@@ -1676,6 +1739,75 @@ public class JavaPyE2ETest {
}
}
+ private void commitDeletionVectors(FileStoreTable table, List<E2eDvSpec>
deletionVectorSpecs)
+ throws Exception {
+ BaseAppendDeleteFileMaintainer maintainer =
+ BaseAppendDeleteFileMaintainer.forUnawareAppend(
+ table.store().newIndexFileHandler(),
+ table.latestSnapshot().get(),
+ BinaryRow.EMPTY_ROW);
+ Map<Range, String> anchorFiles = anchorFilesByRange(table);
+
+ for (E2eDvSpec spec : deletionVectorSpecs) {
+ DeletionVector deletionVector = new BitmapDeletionVector();
+ for (long rowId : spec.deletedRowIds) {
+ deletionVector.delete(rowId - spec.range.from);
+ }
+ maintainer.notifyNewDeletionVector(anchorFiles.get(spec.range),
deletionVector);
+ }
+
+ List<IndexFileMeta> newIndexFiles = new ArrayList<>();
+ List<IndexFileMeta> deletedIndexFiles = new ArrayList<>();
+ for (IndexManifestEntry entry : maintainer.persist()) {
+ if (entry.kind() == FileKind.ADD) {
+ newIndexFiles.add(entry.indexFile());
+ } else if (entry.kind() == FileKind.DELETE) {
+ deletedIndexFiles.add(entry.indexFile());
+ }
+ }
+
+ table.newBatchWriteBuilder()
+ .newCommit()
+ .commit(
+ Collections.singletonList(
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ UNAWARE_BUCKET,
+ null,
+ new DataIncrement(
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList(),
+ newIndexFiles,
+ deletedIndexFiles),
+ CompactIncrement.emptyIncrement())));
+ }
+
+ private Map<Range, String> anchorFilesByRange(FileStoreTable table) {
+ List<DataFileMeta> dataFiles =
+ table.store().newScan().plan().files().stream()
+ .map(ManifestEntry::file)
+ .collect(Collectors.toList());
+ RangeHelper<DataFileMeta> rangeHelper = new
RangeHelper<>(DataFileMeta::nonNullRowIdRange);
+ Map<Range, String> result = new HashMap<>();
+ for (List<DataFileMeta> group :
rangeHelper.mergeOverlappingRanges(dataFiles)) {
+ DataFileMeta anchor = retrieveAnchorFile(group, file -> file);
+ result.put(anchor.nonNullRowIdRange(), anchor.fileName());
+ }
+ return result;
+ }
+
+ private static class E2eDvSpec {
+
+ private final Range range;
+ private final long[] deletedRowIds;
+
+ private E2eDvSpec(Range range, long... deletedRowIds) {
+ this.range = range;
+ this.deletedRowIds = deletedRowIds;
+ }
+ }
+
private void setFirstRowId(List<CommitMessage> messages, long firstRowId) {
messages.forEach(
c -> {
diff --git a/paimon-python/dev/run_mixed_tests.sh
b/paimon-python/dev/run_mixed_tests.sh
index efd066524c..40839700b7 100755
--- a/paimon-python/dev/run_mixed_tests.sh
+++ b/paimon-python/dev/run_mixed_tests.sh
@@ -825,6 +825,30 @@ run_data_evolution_test() {
return 0
}
+run_data_evolution_deletion_vector_test() {
+ echo -e "${YELLOW}=== Running Data Evolution Deletion Vector Test (Java
Write, Python Read) ===${NC}"
+
+ cd "$PROJECT_ROOT"
+
+ echo "Running Maven test for
JavaPyE2ETest.testDataEvolutionDeletionVectorWrite..."
+ if mvn test
-Dtest=org.apache.paimon.JavaPyE2ETest#testDataEvolutionDeletionVectorWrite -pl
paimon-core -q -Drun.e2e.tests=true; then
+ echo -e "${GREEN}✓ Java data evolution deletion vector write completed
successfully${NC}"
+ else
+ echo -e "${RED}✗ Java data evolution deletion vector write failed${NC}"
+ return 1
+ fi
+
+ cd "$PAIMON_PYTHON_DIR"
+ echo "Running Python test for
JavaPyReadWriteTest.test_read_data_evolution_deletion_vector_table..."
+ if python -m pytest
java_py_read_write_test.py::JavaPyReadWriteTest::test_read_data_evolution_deletion_vector_table
-v; then
+ echo -e "${GREEN}✓ Python data evolution deletion vector read
completed successfully${NC}"
+ return 0
+ else
+ echo -e "${RED}✗ Python data evolution deletion vector read
failed${NC}"
+ return 1
+ fi
+}
+
run_data_evolution_py_write_test() {
echo -e "${YELLOW}=== Running Data Evolution Test (Python Write, Java
Read) ===${NC}"
@@ -1009,6 +1033,7 @@ main() {
local blob_compact_conflict_result=0
local blob_alter_compact_result=0
local data_evolution_result=0
+ local data_evolution_deletion_vector_result=0
local data_evolution_py_write_result=0
local vector_dedicated_java_write_result=0
local vector_dedicated_py_write_result=0
@@ -1230,6 +1255,13 @@ main() {
echo ""
+ # Run data evolution deletion vector test (Java write, Python read)
+ if ! run_data_evolution_deletion_vector_test; then
+ data_evolution_deletion_vector_result=1
+ fi
+
+ echo ""
+
# Run data evolution test (Python write, Java read)
if ! run_data_evolution_py_write_test; then
data_evolution_py_write_result=1
@@ -1409,6 +1441,12 @@ main() {
echo -e "${RED}✗ Data Evolution Test (Java Write, Python Read):
FAILED${NC}"
fi
+ if [[ $data_evolution_deletion_vector_result -eq 0 ]]; then
+ echo -e "${GREEN}✓ Data Evolution Deletion Vector Test (Java Write,
Python Read): PASSED${NC}"
+ else
+ echo -e "${RED}✗ Data Evolution Deletion Vector Test (Java Write,
Python Read): FAILED${NC}"
+ fi
+
if [[ $data_evolution_py_write_result -eq 0 ]]; then
echo -e "${GREEN}✓ Data Evolution Test (Python Write, Java Read):
PASSED${NC}"
else
@@ -1438,7 +1476,7 @@ main() {
# Clean up warehouse directory after all tests
cleanup_warehouse
- if [[ $java_write_result -eq 0 && $python_read_result -eq 0 &&
$python_write_result -eq 0 && $java_read_result -eq 0 && $pk_dv_result -eq 0 &&
$btree_index_result -eq 0 && $btree_raw_fallback_result -eq 0 &&
$bitmap_index_result -eq 0 && $compressed_global_index_result -eq 0 &&
$compressed_text_result -eq 0 && $tantivy_fulltext_result -eq 0 &&
$lumina_vector_result -eq 0 && $lumina_vector_btree_result -eq 0 &&
$vindex_vector_result -eq 0 && $vindex_vector_raw_fallback_result -eq 0 && [...]
+ if [[ $java_write_result -eq 0 && $python_read_result -eq 0 &&
$python_write_result -eq 0 && $java_read_result -eq 0 && $pk_dv_result -eq 0 &&
$btree_index_result -eq 0 && $btree_raw_fallback_result -eq 0 &&
$bitmap_index_result -eq 0 && $compressed_global_index_result -eq 0 &&
$compressed_text_result -eq 0 && $tantivy_fulltext_result -eq 0 &&
$lumina_vector_result -eq 0 && $lumina_vector_btree_result -eq 0 &&
$vindex_vector_result -eq 0 && $vindex_vector_raw_fallback_result -eq 0 && [...]
echo -e "${GREEN}🎉 All tests passed! Java-Python interoperability
verified.${NC}"
return 0
else
diff --git a/paimon-python/pypaimon/deletionvectors/__init__.py
b/paimon-python/pypaimon/deletionvectors/__init__.py
index aa7e77dad5..b208082a46 100644
--- a/paimon-python/pypaimon/deletionvectors/__init__.py
+++ b/paimon-python/pypaimon/deletionvectors/__init__.py
@@ -17,11 +17,16 @@
from pypaimon.deletionvectors.deletion_vector import DeletionVector
from pypaimon.deletionvectors.bitmap_deletion_vector import
BitmapDeletionVector
-from pypaimon.deletionvectors.apply_deletion_vector_reader import
ApplyDeletionVectorReader, ApplyDeletionRecordIterator
+from pypaimon.deletionvectors.apply_deletion_vector_reader import (
+ ApplyDeletionVectorReader,
+ ApplyDeletionRecordIterator,
+ PositionMappedDeletionVector,
+)
__all__ = [
'DeletionVector',
'BitmapDeletionVector',
'ApplyDeletionVectorReader',
- 'ApplyDeletionRecordIterator'
+ 'ApplyDeletionRecordIterator',
+ 'PositionMappedDeletionVector',
]
diff --git
a/paimon-python/pypaimon/deletionvectors/apply_deletion_vector_reader.py
b/paimon-python/pypaimon/deletionvectors/apply_deletion_vector_reader.py
index 2642b7dc84..f491ecfa3f 100644
--- a/paimon-python/pypaimon/deletionvectors/apply_deletion_vector_reader.py
+++ b/paimon-python/pypaimon/deletionvectors/apply_deletion_vector_reader.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-from typing import Optional
+from typing import List, Optional
import pyarrow
from pyarrow import RecordBatch
@@ -23,45 +23,83 @@ from pyarrow import RecordBatch
from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
from pypaimon.read.reader.iface.record_iterator import RecordIterator
from pypaimon.deletionvectors.deletion_vector import DeletionVector
-from pypaimon.utils.roaring_bitmap import RoaringBitmap
from pypaimon.read.reader.iface.record_reader import RecordReader
+class PositionMappedDeletionVector:
+ """
+ Adapts a deletion vector to the returned positions of the current reader.
+ """
+
+ def __init__(
+ self,
+ deletion_vector: DeletionVector,
+ file_offset: int = 0,
+ row_positions: Optional[List[int]] = None,
+ ):
+ self._deletion_vector = deletion_vector
+ self._file_offset = file_offset
+ self._row_positions = row_positions
+
+ def is_deleted(self, position: int) -> bool:
+ return
self._deletion_vector.is_deleted(self._mapped_position(position))
+
+ def _mapped_position(self, position: int) -> int:
+ if self._row_positions is None:
+ return self._file_offset + position
+ if position >= len(self._row_positions):
+ raise ValueError(
+ "Deletion vector row positions are fewer than returned rows."
+ )
+ return self._file_offset + self._row_positions[position]
+
+
class ApplyDeletionVectorReader(RecordBatchReader):
"""
A RecordReader which applies DeletionVector to filter records.
"""
- def __init__(self, reader: RecordReader, deletion_vector: DeletionVector):
+ def __init__(
+ self,
+ reader: RecordReader,
+ deletion_vector,
+ ):
"""
Initialize an ApplyDeletionVectorReader.
Args:
reader: The underlying record reader.
- deletion_vector: The deletion vector to apply.
+ deletion_vector: The deletion vector to apply. It should already
+ be mapped to the returned positions of the underlying reader.
"""
self._reader = reader
self._deletion_vector = deletion_vector
+ self._returned_position = 0
def reader(self) -> RecordReader:
return self._reader
- def deletion_vector(self) -> DeletionVector:
+ def deletion_vector(self):
return self._deletion_vector
def read_arrow_batch(self) -> Optional[RecordBatch]:
self._reader: RecordBatchReader
- arrow_batch = self._reader.read_arrow_batch()
- if arrow_batch is None:
- return None
- # Remove the deleted rows from the batch
- range_bitmap = RoaringBitmap()
- return_batch_pos = self._reader.return_batch_pos()
- range_bitmap.add_range(return_batch_pos - arrow_batch.num_rows,
return_batch_pos - 1)
- intersection_bitmap = RoaringBitmap.remove_all(range_bitmap,
self._deletion_vector.bit_map())
- added_row_list = [x - (return_batch_pos - arrow_batch.num_rows) for x
in
- list(intersection_bitmap)]
- return arrow_batch.take(pyarrow.array(added_row_list,
type=pyarrow.int32()))
+ # Skip physical batches that become empty after DV filtering. Some
merge readers
+ # treat a zero-row batch as EOF and would otherwise stop before later
live rows.
+ while True:
+ arrow_batch = self._reader.read_arrow_batch()
+ if arrow_batch is None:
+ return None
+
+ start = self._returned_position
+ end = start + arrow_batch.num_rows
+ self._returned_position = end
+ keep_indices = [
+ i for i, position in enumerate(range(start, end))
+ if not self._deletion_vector.is_deleted(position)
+ ]
+ if keep_indices:
+ return arrow_batch.take(pyarrow.array(keep_indices,
type=pyarrow.int32()))
def read_batch(self) -> Optional[RecordIterator]:
"""
@@ -87,7 +125,11 @@ class ApplyDeletionRecordIterator(RecordIterator):
to filter out deleted records.
"""
- def __init__(self, iterator: RecordIterator, deletion_vector:
DeletionVector):
+ def __init__(
+ self,
+ iterator: RecordIterator,
+ deletion_vector,
+ ):
"""
Initialize an ApplyDeletionRecordIterator.
@@ -97,15 +139,16 @@ class ApplyDeletionRecordIterator(RecordIterator):
"""
self._iterator = iterator
self._deletion_vector = deletion_vector
+ self._returned_position = -1
def iterator(self) -> RecordIterator:
return self._iterator
- def deletion_vector(self) -> DeletionVector:
+ def deletion_vector(self):
return self._deletion_vector
def returned_position(self) -> int:
- return self._iterator.return_pos()
+ return self._returned_position
def next(self) -> Optional[object]:
"""
@@ -123,6 +166,7 @@ class ApplyDeletionRecordIterator(RecordIterator):
if record is None:
return None
- # Check if the current position is deleted
- if not
self._deletion_vector.is_deleted(self._iterator.return_pos()):
+ self._returned_position += 1
+ position = self._returned_position
+ if not self._deletion_vector.is_deleted(position):
return record
diff --git a/paimon-python/pypaimon/read/reader/concat_batch_reader.py
b/paimon-python/pypaimon/read/reader/concat_batch_reader.py
index 67d1a7c40a..51b6f573a6 100644
--- a/paimon-python/pypaimon/read/reader/concat_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/concat_batch_reader.py
@@ -126,6 +126,7 @@ class MergeAllBatchReader(RecordBatchReader):
)
else:
self.merged_batch = None
+ return None
dataset = ds.InMemoryDataset(self.merged_batch)
self.reader = dataset.scanner(batch_size=self._batch_size).to_reader()
return self.reader.read_next_batch()
@@ -240,12 +241,17 @@ 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):
+ blob_as_descriptor: bool = False, deletion_vector=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._blob_as_descriptor = blob_as_descriptor
+ if deletion_vector is None:
+ self._deletion_vector_range = None
+ self._deletion_vector = None
+ else:
+ self._deletion_vector_range, self._deletion_vector =
deletion_vector
self._returned = False
self._readers: List[RecordBatchReader] = []
@@ -319,7 +325,11 @@ class BlobFallbackBatchReader(RecordBatchReader):
]
if self._row_ranges is not None:
ranges = Range.and_(ranges, self._row_ranges)
- return self._expand_ranges(ranges)
+ return [
+ row_id
+ for row_id in self._expand_ranges(ranges)
+ if not self._is_deleted(row_id)
+ ]
def _selected_row_ids(self, file: DataFileMeta) -> List[int]:
ranges = [file.row_id_range()]
@@ -335,6 +345,18 @@ class BlobFallbackBatchReader(RecordBatchReader):
for row_id in range(row_range.from_, row_range.to + 1)
]
+ def _is_deleted(self, row_id: int) -> bool:
+ if self._deletion_vector is None:
+ return False
+ if not self._deletion_vector_range.contains(row_id):
+ raise ValueError(
+ f"Deletion vector range {self._deletion_vector_range} "
+ f"should contain blob row id {row_id}."
+ )
+ return self._deletion_vector.is_deleted(
+ row_id - self._deletion_vector_range.from_
+ )
+
def _read_blob_values(self, file: DataFileMeta, supplier: Callable) ->
List[object]:
reader = supplier()
if reader is None:
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index fe46abd67b..d6c7ad6f36 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -565,6 +565,8 @@ class FileScanner:
"""
if self.limit is None:
return splits
+ if self.data_evolution and self.deletion_vectors_enabled:
+ return splits
if self._has_non_partition_filter():
return splits
diff --git a/paimon-python/pypaimon/read/split.py
b/paimon-python/pypaimon/read/split.py
index 46513656ae..c318a15b2c 100644
--- a/paimon-python/pypaimon/read/split.py
+++ b/paimon-python/pypaimon/read/split.py
@@ -184,6 +184,10 @@ class DataSplit(Split):
for file in self._files:
if file.first_row_id is None:
return False
+ if self.data_deletion_files is not None:
+ for deletion_file in self.data_deletion_files:
+ if deletion_file is not None and deletion_file.cardinality is
None:
+ return False
return True
def _data_evolution_merged_row_count(self) -> int:
@@ -198,4 +202,11 @@ class DataSplit(Split):
return 0
ranges = Range.sort_and_merge_overlap(file_ranges, True, True)
- return sum([r.count() for r in ranges])
+ row_count = sum([r.count() for r in ranges])
+ if self.data_deletion_files is not None:
+ row_count -= sum(
+ deletion_file.cardinality
+ for deletion_file in self.data_deletion_files
+ if deletion_file is not None
+ )
+ return row_count
diff --git a/paimon-python/pypaimon/read/split_read.py
b/paimon-python/pypaimon/read/split_read.py
index 231b525bbf..fce5378956 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -23,7 +23,10 @@ from typing import Callable, Dict, List, Optional, Tuple
from pypaimon.common.merge_engine_dispatch import build_merge_function
from pypaimon.common.options.core_options import CoreOptions, MergeEngine
from pypaimon.common.predicate import Predicate
-from pypaimon.deletionvectors import ApplyDeletionVectorReader
+from pypaimon.deletionvectors import (
+ ApplyDeletionVectorReader,
+ PositionMappedDeletionVector,
+)
from pypaimon.deletionvectors.deletion_vector import DeletionVector
from pypaimon.globalindex import Range
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
@@ -68,6 +71,7 @@ from pypaimon.read.sliced_split import SlicedSplit
from pypaimon.schema.data_types import DataField, PyarrowFieldParser
from pypaimon.table.special_fields import SpecialFields
from pypaimon.globalindex.indexed_split import IndexedSplit
+from pypaimon.utils.data_evolution_utils import retrieve_anchor_file
KEY_PREFIX = "_KEY_"
KEY_FIELD_ID_START = 1000000
@@ -1009,19 +1013,22 @@ class DataEvolutionSplitRead(SplitRead):
"""Core read logic: split_by_row_id -> suppliers -> ConcatBatchReader
-> filter."""
files = self.split.files
suppliers = []
+ self._genarate_deletion_file_readers()
# Split files by row ID
split_by_row_id = self._split_by_row_id(files)
for need_merge_files in split_by_row_id:
+ deletion_vector = self._read_deletion_vector(need_merge_files)
if len(need_merge_files) == 1 or not self.read_fields:
# No need to merge fields, just create a single file reader
suppliers.append(
- lambda f=need_merge_files[0]: self._create_file_reader(f,
self._get_final_read_data_fields())
+ lambda f=need_merge_files[0], dv=deletion_vector:
self._create_file_reader(
+ f, self._get_final_read_data_fields(), dv)
)
else:
suppliers.append(
- lambda files=need_merge_files:
self._create_union_reader(files)
+ lambda files=need_merge_files, dv=deletion_vector:
self._create_union_reader(files, dv)
)
merge_reader = ConcatBatchReader(
@@ -1043,6 +1050,51 @@ class DataEvolutionSplitRead(SplitRead):
return reader
+ def _read_deletion_vector(self, need_merge_files: List[DataFileMeta]):
+ if not getattr(self, "deletion_file_readers", None):
+ return None
+
+ anchor = retrieve_anchor_file(need_merge_files)
+ dv_factory = self.deletion_file_readers.get(anchor.file_name)
+ if dv_factory is None:
+ return None
+
+ deletion_vector = dv_factory()
+ if deletion_vector is None:
+ return None
+
+ return anchor.row_id_range(), deletion_vector
+
+ def _apply_deletion_vector(self, reader, reader_range: Range,
deletion_vector):
+ if reader is None or deletion_vector is None:
+ return reader
+
+ dv_range, dv = deletion_vector
+ if dv.is_empty():
+ return reader
+
+ if dv_range.from_ > reader_range.from_ or dv_range.to <
reader_range.to:
+ raise ValueError(
+ f"Deletion vector range {dv_range} should contain reader range
{reader_range}."
+ )
+
+ mapped_dv = PositionMappedDeletionVector(
+ dv,
+ reader_range.from_ - dv_range.from_,
+ self._selected_local_positions(reader_range),
+ )
+ return ApplyDeletionVectorReader(reader, mapped_dv)
+
+ def _selected_local_positions(self, reader_range: Range) ->
Optional[List[int]]:
+ if self.row_ranges is None:
+ return None
+ selected = Range.and_([reader_range], self.row_ranges)
+ return [
+ row_id - reader_range.from_
+ for row_range in selected
+ for row_id in range(row_range.from_, row_range.to + 1)
+ ]
+
def _create_prescan_reader(self, field_names):
"""Create a prescan reader by constructing a new DataEvolutionSplitRead
instance that only projects the specified field names.
@@ -1116,7 +1168,7 @@ class DataEvolutionSplitRead(SplitRead):
return split_by_row_id
- def _create_union_reader(self, need_merge_files: List[DataFileMeta]) ->
RecordReader:
+ def _create_union_reader(self, need_merge_files: List[DataFileMeta],
deletion_vector=None) -> RecordReader:
"""Create a DataEvolutionFileReader for merging multiple files."""
# Split field bunches
fields_files = self._split_field_bunches(need_merge_files)
@@ -1187,10 +1239,13 @@ class DataEvolutionSplitRead(SplitRead):
table_fields = self.read_fields
self.read_fields = read_fields # create reader based on
read_fields
batch_size = self.table.options.read_batch_size()
- # Create reader for this bunch
+ # DataEvolutionMergeReader aligns fields by row ordinal, so
every
+ # 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
+ 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):
@@ -1213,12 +1268,14 @@ class DataEvolutionSplitRead(SplitRead):
).field(0).type,
self.row_ranges,
CoreOptions.blob_as_descriptor(self.table.options),
+ deletion_vector=deletion_vector,
)
else:
# Create concatenated reader for multiple files
suppliers = [
partial(self._create_file_reader, file=file,
- read_fields=read_field_names) for file in
bunch.files()
+ read_fields=read_field_names,
+ deletion_vector=deletion_vector) for file in
bunch.files()
]
file_record_readers[i] = MergeAllBatchReader(suppliers,
batch_size=batch_size)
self.read_fields = table_fields
@@ -1232,14 +1289,16 @@ class DataEvolutionSplitRead(SplitRead):
output_schema = PyarrowFieldParser.from_paimon_schema(all_read_fields)
return DataEvolutionMergeReader(row_offsets, field_offsets,
file_record_readers, schema=output_schema)
- def _create_file_reader(self, file: DataFileMeta, read_fields: [str]) ->
Optional[RecordReader]:
+ def _create_file_reader(
+ self, file: DataFileMeta, read_fields: [str],
deletion_vector=None) -> Optional[RecordReader]:
"""Create a file reader for a single file."""
- return self.file_reader_supplier(
+ reader = self.file_reader_supplier(
file=file,
for_merge_read=False,
read_fields=read_fields,
row_tracking_enabled=True,
row_ranges=self.row_ranges)
+ return self._apply_deletion_vector(reader, file.row_id_range(),
deletion_vector)
def _create_raw_blob_file_reader(
self, file: DataFileMeta, read_fields: [str]) ->
Optional[FormatBlobReader]:
diff --git
a/paimon-python/pypaimon/tests/data_evolution_deletion_vector_test.py
b/paimon-python/pypaimon/tests/data_evolution_deletion_vector_test.py
new file mode 100644
index 0000000000..650adb95f2
--- /dev/null
+++ b/paimon-python/pypaimon/tests/data_evolution_deletion_vector_test.py
@@ -0,0 +1,270 @@
+# 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.
+
+import unittest
+
+import pyarrow as pa
+
+from pypaimon.deletionvectors.apply_deletion_vector_reader import (
+ ApplyDeletionVectorReader,
+ PositionMappedDeletionVector,
+)
+from pypaimon.deletionvectors.bitmap_deletion_vector import
BitmapDeletionVector
+from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+from pypaimon.manifest.schema.simple_stats import SimpleStats
+from pypaimon.read.reader.concat_batch_reader import (
+ BlobFallbackBatchReader,
+ DataEvolutionMergeReader,
+ MergeAllBatchReader,
+)
+from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
+from pypaimon.read.split import DataSplit
+from pypaimon.table.row.blob import Blob, BlobData
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.table.source.deletion_file import DeletionFile
+from pypaimon.utils.range import Range
+from pypaimon.utils.data_evolution_utils import retrieve_anchor_file
+
+
+class _OneBatchReader(RecordBatchReader):
+ def __init__(self, values):
+ self._batch = pa.record_batch([pa.array(values, type=pa.int64())],
names=["v"])
+ self._returned = False
+
+ def read_arrow_batch(self):
+ if self._returned:
+ return None
+ self._returned = True
+ return self._batch
+
+ def close(self):
+ pass
+
+
+class _BlobFallbackBatchReaderForTest(BlobFallbackBatchReader):
+ def __init__(
+ self, files, values_by_file_name, row_ranges=None, deletion_vector=None
+ ):
+ super().__init__(
+ [(file, lambda: None) for file in files],
+ "blob_col",
+ pa.binary(),
+ row_ranges=row_ranges,
+ blob_as_descriptor=False,
+ deletion_vector=deletion_vector,
+ )
+ self._values_by_file_name = values_by_file_name
+
+ def _read_blob_values(self, file, supplier):
+ values = self._values_by_file_name[file.file_name]
+ return [
+ values[row_id - file.first_row_id]
+ for row_id in self._selected_row_ids(file)
+ ]
+
+
+def _file(name, first_row_id, row_count, max_sequence_number):
+ empty_row = GenericRow([], [])
+ return DataFileMeta(
+ file_name=name,
+ file_size=1,
+ row_count=row_count,
+ min_key=empty_row,
+ max_key=empty_row,
+ key_stats=SimpleStats.empty_stats(),
+ value_stats=SimpleStats.empty_stats(),
+ min_sequence_number=max_sequence_number,
+ max_sequence_number=max_sequence_number,
+ schema_id=0,
+ level=0,
+ extra_files=[],
+ first_row_id=first_row_id,
+ )
+
+
+class DataEvolutionDeletionVectorTest(unittest.TestCase):
+ def test_retrieve_anchor_file_uses_oldest_normal_file(self):
+ files = [
+ _file("field-2.blob", 0, 5, 1),
+ _file("normal-b.parquet", 0, 5, 1),
+ _file("normal-a.parquet", 0, 5, 1),
+ _file("newer.parquet", 0, 5, 2),
+ ]
+
+ self.assertEqual("normal-a.parquet",
retrieve_anchor_file(files).file_name)
+
+ def test_data_evolution_merged_row_count_subtracts_deletion_vectors(self):
+ split = DataSplit(
+ files=[
+ _file("anchor-0.parquet", 0, 5, 1),
+ _file("blob-0.blob", 0, 5, 2),
+ _file("anchor-5.parquet", 5, 5, 3),
+ ],
+ partition=GenericRow([], []),
+ bucket=0,
+ raw_convertible=False,
+ data_deletion_files=[
+ DeletionFile("dv", 0, 1, cardinality=2),
+ None,
+ DeletionFile("dv", 1, 1, cardinality=1),
+ ],
+ )
+
+ self.assertEqual(7, split.merged_row_count())
+
+ def test_data_evolution_merged_row_count_unknown_without_cardinality(self):
+ split = DataSplit(
+ files=[_file("anchor.parquet", 0, 5, 1)],
+ partition=GenericRow([], []),
+ bucket=0,
+ raw_convertible=False,
+ data_deletion_files=[DeletionFile("dv", 0, 1, cardinality=None)],
+ )
+
+ self.assertIsNone(split.merged_row_count())
+
+ def test_apply_deletion_vector_reader_uses_mapped_deletion_vector(self):
+ deletion_vector = BitmapDeletionVector()
+ deletion_vector.delete(12)
+ mapped_dv = PositionMappedDeletionVector(
+ deletion_vector,
+ file_offset=10,
+ row_positions=[0, 2, 4],
+ )
+
+ reader = ApplyDeletionVectorReader(
+ _OneBatchReader([0, 2, 4]),
+ mapped_dv,
+ )
+
+ batch = reader.read_arrow_batch()
+ self.assertEqual([0, 4], batch.column(0).to_pylist())
+ self.assertTrue(reader.deletion_vector().is_deleted(1))
+ self.assertFalse(reader.deletion_vector().is_deleted(2))
+
+ def test_data_evolution_merge_reader_handles_fully_deleted_file(self):
+ deletion_vector = BitmapDeletionVector()
+ deletion_vector.delete(0)
+ deletion_vector.delete(1)
+
+ field_reader = MergeAllBatchReader([
+ lambda: ApplyDeletionVectorReader(
+ _OneBatchReader([0, 1]),
+ deletion_vector,
+ )
+ ])
+ reader = DataEvolutionMergeReader(
+ row_offsets=[0],
+ field_offsets=[0],
+ readers=[field_reader],
+ schema=pa.schema([pa.field("v", pa.int64())]),
+ )
+
+ self.assertIsNone(reader.read_arrow_batch())
+
+ def test_blob_fallback_batch_reader_applies_deletion_vector(self):
+ files = [
+ _file("blob-old.blob", 0, 5, 1),
+ _file("blob-new.blob", 0, 5, 2),
+ ]
+ deletion_vector = BitmapDeletionVector()
+ deletion_vector.delete(1)
+ deletion_vector.delete(4)
+
+ reader = _BlobFallbackBatchReaderForTest(
+ files,
+ {
+ "blob-old.blob": [
+ BlobData(b"old-0"),
+ BlobData(b"old-1"),
+ BlobData(b"old-2"),
+ BlobData(b"old-3"),
+ BlobData(b"old-4"),
+ ],
+ "blob-new.blob": [
+ Blob.PLACE_HOLDER,
+ BlobData(b"new-1"),
+ BlobData(b"new-2"),
+ Blob.PLACE_HOLDER,
+ BlobData(b"new-4"),
+ ],
+ },
+ deletion_vector=(Range(0, 4), deletion_vector),
+ )
+
+ batch = reader.read_arrow_batch()
+ self.assertEqual(
+ [b"old-0", b"new-2", b"old-3"],
+ batch.column(0).to_pylist(),
+ )
+ self.assertIsNone(reader.read_arrow_batch())
+
+ def
test_data_evolution_merge_reader_aligns_blob_with_row_ranges_and_dv(self):
+ row_ranges = [Range(1, 4)]
+ deletion_vector = BitmapDeletionVector()
+ deletion_vector.delete(2)
+ deletion_vector.delete(4)
+
+ normal_reader = ApplyDeletionVectorReader(
+ _OneBatchReader([1, 2, 3, 4]),
+ PositionMappedDeletionVector(
+ deletion_vector,
+ file_offset=0,
+ row_positions=[1, 2, 3, 4],
+ ),
+ )
+ blob_reader = _BlobFallbackBatchReaderForTest(
+ [
+ _file("blob-old.blob", 0, 6, 1),
+ _file("blob-new.blob", 0, 6, 2),
+ ],
+ {
+ "blob-old.blob": [
+ BlobData(b"old-0"),
+ BlobData(b"old-1"),
+ BlobData(b"old-2"),
+ BlobData(b"old-3"),
+ BlobData(b"old-4"),
+ BlobData(b"old-5"),
+ ],
+ "blob-new.blob": [
+ Blob.PLACE_HOLDER,
+ Blob.PLACE_HOLDER,
+ BlobData(b"new-2"),
+ BlobData(b"new-3"),
+ BlobData(b"new-4"),
+ Blob.PLACE_HOLDER,
+ ],
+ },
+ row_ranges=row_ranges,
+ deletion_vector=(Range(0, 5), deletion_vector),
+ )
+ reader = DataEvolutionMergeReader(
+ row_offsets=[0, 1],
+ field_offsets=[0, 0],
+ readers=[normal_reader, blob_reader],
+ schema=pa.schema([pa.field("id", pa.int64()), pa.field("blob_col",
pa.binary())]),
+ )
+
+ batch = reader.read_arrow_batch()
+ self.assertEqual([1, 3], batch.column(0).to_pylist())
+ self.assertEqual([b"old-1", b"new-3"], batch.column(1).to_pylist())
+ self.assertIsNone(reader.read_arrow_batch())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
index ad66dd26f1..aad6f5133a 100644
--- a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
+++ b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
@@ -1473,6 +1473,28 @@ class JavaPyReadWriteTest(unittest.TestCase):
self.assertEqual(result.column('f1')[i].as_py(), f'a{i}')
self.assertEqual(result.column('f2')[i].as_py(), f'b{i}')
+ def test_read_data_evolution_deletion_vector_table(self):
+ """Read a data evolution table with deletion vectors and blob files
written by Java."""
+ table = self.catalog.get_table('default.data_evolution_dv_test')
+ read_builder = table.new_read_builder()
+ table_scan = read_builder.new_scan()
+ table_read = read_builder.new_read()
+ result = table_read.to_arrow(table_scan.plan().splits())
+ result = table_sort_by(result, 'f0')
+
+ expected_ids = [0, 2, 3, 11, 13, 14]
+ self.assertEqual(result.num_rows, len(expected_ids))
+ self.assertEqual(result.column('f0').to_pylist(), expected_ids)
+ self.assertEqual(
+ result.column('f1').to_pylist(),
+ [f'name-{i}' for i in expected_ids])
+ self.assertEqual(
+ result.column('f2').to_pylist(),
+ [f'base-{i}' for i in expected_ids])
+ self.assertEqual(
+ result.column('f3').to_pylist(),
+ [bytes([i]) for i in expected_ids])
+
@parameterized.expand(get_file_format_params())
def test_py_write_data_evolution_table(self, file_format):
"""Python writes data evolution tables for Java to read."""
diff --git a/paimon-python/pypaimon/tests/reader_split_generator_test.py
b/paimon-python/pypaimon/tests/reader_split_generator_test.py
index b29d88dc23..32ae1ddbf2 100644
--- a/paimon-python/pypaimon/tests/reader_split_generator_test.py
+++ b/paimon-python/pypaimon/tests/reader_split_generator_test.py
@@ -333,7 +333,12 @@ class ApplyPushDownLimitUnitTest(unittest.TestCase):
"""Mock-driven coverage of ``FileScanner._apply_push_down_limit``."""
@staticmethod
- def _apply(splits, limit, has_non_partition_filter=False):
+ def _apply(
+ splits,
+ limit,
+ has_non_partition_filter=False,
+ data_evolution=False,
+ deletion_vectors_enabled=False):
from pypaimon.read.scanner.file_scanner import FileScanner
class _FakeScanner:
@@ -341,6 +346,8 @@ class ApplyPushDownLimitUnitTest(unittest.TestCase):
scanner = _FakeScanner()
scanner.limit = limit
+ scanner.data_evolution = data_evolution
+ scanner.deletion_vectors_enabled = deletion_vectors_enabled
scanner._has_non_partition_filter = lambda: has_non_partition_filter
return FileScanner._apply_push_down_limit(scanner, splits)
@@ -399,6 +406,16 @@ class ApplyPushDownLimitUnitTest(unittest.TestCase):
[s_raw, s_raw, s_raw], limit=5, has_non_partition_filter=True)
self.assertEqual(len(result), 3)
+ def test_data_evolution_deletion_vectors_disable_limit_pushdown(self):
+ s_raw = self._split(raw_convertible=True, row_count=10,
merged_row_count=10)
+ result = self._apply(
+ [s_raw, s_raw, s_raw],
+ limit=5,
+ data_evolution=True,
+ deletion_vectors_enabled=True,
+ )
+ self.assertEqual(len(result), 3)
+
if __name__ == '__main__':
unittest.main()
diff --git a/paimon-python/pypaimon/utils/data_evolution_utils.py
b/paimon-python/pypaimon/utils/data_evolution_utils.py
new file mode 100644
index 0000000000..06c5d36265
--- /dev/null
+++ b/paimon-python/pypaimon/utils/data_evolution_utils.py
@@ -0,0 +1,51 @@
+# 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.
+
+"""Utilities for data-evolution tables."""
+
+from typing import Callable, Iterable, TypeVar
+
+from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+
+T = TypeVar("T")
+
+
+def retrieve_anchor_file(
+ entries: Iterable[T],
+ file_meta_func: Callable[[T], DataFileMeta] = lambda entry: entry,
+) -> T:
+ """Return the oldest normal file in a data-evolution row-range group."""
+ anchor = None
+ anchor_key = None
+
+ for entry in entries:
+ meta = file_meta_func(entry)
+ if DataFileMeta.is_blob_file(meta.file_name) or
DataFileMeta.is_vector_file(meta.file_name):
+ continue
+
+ key = (meta.max_sequence_number, meta.file_name)
+ if anchor_key is None or key < anchor_key:
+ anchor = entry
+ anchor_key = key
+
+ if anchor is None:
+ raise ValueError(
+ "Data-evolution deletion vectors should have a normal anchor file "
+ "in each row range group."
+ )
+
+ return anchor