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 cad041961c [core] add a `deleted_record_count` field in
PartitionsTable (#8526)
cad041961c is described below
commit cad041961c3e577d706d6f8341ef7dd82ee66fa9
Author: Faiz <[email protected]>
AuthorDate: Mon Jul 13 23:25:58 2026 +0800
[core] add a `deleted_record_count` field in PartitionsTable (#8526)
---
docs/docs/concepts/system-tables.mdx | 11 ++-
docs/docs/pypaimon/system-tables.md | 1 +
.../paimon/table/system/PartitionsTable.java | 74 ++++++++++++++-
.../paimon/table/system/PartitionsTableTest.java | 105 +++++++++++++++++++++
.../pypaimon/table/system/partitions_table.py | 42 +++++++++
.../pypaimon/tests/system/partitions_table_test.py | 67 ++++++++++++-
6 files changed, 290 insertions(+), 10 deletions(-)
diff --git a/docs/docs/concepts/system-tables.mdx
b/docs/docs/concepts/system-tables.mdx
index 29806d7435..d70bc6c39b 100644
--- a/docs/docs/concepts/system-tables.mdx
+++ b/docs/docs/concepts/system-tables.mdx
@@ -439,16 +439,17 @@ You can query the partition files of the table.
SELECT * FROM my_table$partitions;
/*
-+-----------+--------------+-------------------+------------+---------------------+---------------------+------------+------------+---------+---------------+-------+
-| partition | record_count | file_size_in_bytes| file_count | last_update_time
| created_at | created_by | updated_by | options | total_buckets |
done |
-+-----------+--------------+-------------------+------------+---------------------+---------------------+------------+------------+---------+---------------+-------+
-| {1} | 1 | 645 | 1 | 2024-06-24
10:25:57 | 2024-06-24 10:20:00 | admin | test_user | {} |
1 | false |
-+-----------+--------------+-------------------+------------+---------------------+---------------------+------------+------------+---------+---------------+-------+
++-----------+--------------+-------------------+------------+---------------------+---------------------+------------+------------+---------+---------------+-------+----------------------+
+| partition | record_count | file_size_in_bytes| file_count | last_update_time
| created_at | created_by | updated_by | options | total_buckets |
done | deleted_record_count |
++-----------+--------------+-------------------+------------+---------------------+---------------------+------------+------------+---------+---------------+-------+----------------------+
+| {1} | 1 | 645 | 1 | 2024-06-24
10:25:57 | 2024-06-24 10:20:00 | admin | test_user | {} |
1 | false | 0 |
++-----------+--------------+-------------------+------------+---------------------+---------------------+------------+------------+---------+---------------+-------+----------------------+
*/
```
**Note**:
- The `created_by`, `created_at`, `updated_by`, and `options` fields are
populated from REST catalog audit information. For non-REST catalogs, these
fields will be `NULL`.
+- The `deleted_record_count` field is the number of records marked deleted by
deletion vectors in the partition. It is `0` when the partition has no deletion
vectors and `NULL` when legacy deletion vector metadata does not contain
cardinality.
### Buckets Table
diff --git a/docs/docs/pypaimon/system-tables.md
b/docs/docs/pypaimon/system-tables.md
index 5f82f6c351..fdbf287745 100644
--- a/docs/docs/pypaimon/system-tables.md
+++ b/docs/docs/pypaimon/system-tables.md
@@ -178,6 +178,7 @@ Aggregated partition statistics for the latest snapshot.
| `options` | STRING | Filesystem path returns
`NULL` |
| `total_buckets` | INT NOT NULL |
|
| `done` | BOOLEAN NOT NULL | Filesystem path returns
`False`|
+| `deleted_record_count` | BIGINT | Records marked deleted by
deletion vectors; `0` when none exist and `NULL` when legacy metadata lacks
cardinality |
### `$tags`
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/system/PartitionsTable.java
b/paimon-core/src/main/java/org/apache/paimon/table/system/PartitionsTable.java
index 1291308e5a..14b02c86e1 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/system/PartitionsTable.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/system/PartitionsTable.java
@@ -19,6 +19,7 @@
package org.apache.paimon.table.system;
import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
import org.apache.paimon.casting.CastExecutor;
import org.apache.paimon.casting.CastExecutors;
import org.apache.paimon.catalog.Catalog;
@@ -32,10 +33,15 @@ import org.apache.paimon.data.Timestamp;
import org.apache.paimon.data.serializer.InternalRowSerializer;
import org.apache.paimon.disk.IOManager;
import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.index.DeletionVectorMeta;
+import org.apache.paimon.index.IndexFileHandler;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.manifest.IndexManifestEntry;
import org.apache.paimon.manifest.PartitionEntry;
import org.apache.paimon.options.Options;
import org.apache.paimon.partition.Partition;
import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateVisitor;
import org.apache.paimon.reader.RecordReader;
import org.apache.paimon.table.FileStoreTable;
import org.apache.paimon.table.ReadonlyTable;
@@ -70,7 +76,9 @@ import java.time.ZoneId;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
+import java.util.HashMap;
import java.util.Iterator;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -78,6 +86,7 @@ import java.util.OptionalLong;
import java.util.stream.Collectors;
import static org.apache.paimon.catalog.Identifier.SYSTEM_TABLE_SPLITTER;
+import static
org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
/** A {@link Table} for showing partitions info. */
public class PartitionsTable implements ReadonlyTable {
@@ -99,7 +108,8 @@ public class PartitionsTable implements ReadonlyTable {
new DataField(7, "updated_by", DataTypes.STRING()),
new DataField(8, "options", DataTypes.STRING()),
new DataField(9, "total_buckets",
DataTypes.INT().notNull()),
- new DataField(10, "done",
DataTypes.BOOLEAN().notNull())));
+ new DataField(10, "done",
DataTypes.BOOLEAN().notNull()),
+ new DataField(11, "deleted_record_count", new
BigIntType(true))));
private final FileStoreTable storeTable;
@@ -224,6 +234,8 @@ public class PartitionsTable implements ReadonlyTable {
.map(CastExecutors::resolveToString)
.collect(Collectors.toList());
+ Map<BinaryRow, Long> deletionNums = deletionNums();
+
// sorted by partition
Iterator<InternalRow> iterator =
partitions.stream()
@@ -236,7 +248,8 @@ public class PartitionsTable implements ReadonlyTable {
fieldGetters,
fileStoreTable
.coreOptions()
-
.partitionDefaultName()))
+
.partitionDefaultName(),
+ deletionNums))
.sorted(Comparator.comparing(row ->
row.getString(0)))
.iterator();
@@ -260,7 +273,8 @@ public class PartitionsTable implements ReadonlyTable {
List<String> partitionKeys,
List<CastExecutor> castExecutors,
InternalRow.FieldGetter[] fieldGetters,
- String defaultPartitionName) {
+ String defaultPartitionName,
+ @Nullable Map<BinaryRow, Long> deletionNums) {
PartitionEntry entry = toPartitionEntry(partition);
StringBuilder partitionStringBuilder = new StringBuilder();
@@ -303,7 +317,59 @@ public class PartitionsTable implements ReadonlyTable {
updatedByString,
optionsString,
partition.totalBuckets(),
- partition.done());
+ partition.done(),
+ deletionNums == null ? null :
deletionNums.getOrDefault(entry.partition(), 0L));
+ }
+
+ @Nullable
+ private Map<BinaryRow, Long> deletionNums() {
+ if (!fileStoreTable.coreOptions().deletionVectorsEnabled()) {
+ return Collections.emptyMap();
+ }
+
+ // do not scan deletion indices if query doesn't need them.
+ if (readType != null
+ && !readType.containsField("deleted_record_count")
+ && !PredicateVisitor.collectFieldNames(predicate)
+ .contains("deleted_record_count")) {
+ return null;
+ }
+
+ Snapshot snapshot =
TimeTravelUtil.tryTravelOrLatest(fileStoreTable);
+ if (snapshot == null || snapshot.indexManifest() == null) {
+ return Collections.emptyMap();
+ }
+
+ IndexFileHandler indexFileHandler =
fileStoreTable.store().newIndexFileHandler();
+ Map<BinaryRow, Long> result = new HashMap<>();
+ for (IndexManifestEntry entry :
+ indexFileHandler.scan(snapshot, DELETION_VECTORS_INDEX)) {
+ accumulateDeletionNum(result, entry.partition(),
entry.indexFile());
+ }
+ return result;
+ }
+
+ private void accumulateDeletionNum(
+ Map<BinaryRow, Long> result, BinaryRow partition,
IndexFileMeta indexFile) {
+ if (result.containsKey(partition) && result.get(partition) ==
null) {
+ return;
+ }
+
+ LinkedHashMap<String, DeletionVectorMeta> dvRanges =
indexFile.dvRanges();
+ if (dvRanges == null || dvRanges.isEmpty()) {
+ return;
+ }
+
+ long count = result.getOrDefault(partition, 0L);
+ for (DeletionVectorMeta dvMeta : dvRanges.values()) {
+ Long cardinality = dvMeta.cardinality();
+ if (cardinality == null) {
+ result.put(partition.copy(), null);
+ return;
+ }
+ count += cardinality;
+ }
+ result.put(partition.copy(), count);
}
private PartitionEntry toPartitionEntry(Partition partition) {
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/system/PartitionsTableTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/system/PartitionsTableTest.java
index f79c7811b5..ef18580dc6 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/system/PartitionsTableTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/system/PartitionsTableTest.java
@@ -38,6 +38,7 @@ import org.apache.paimon.table.FileStoreTableFactory;
import org.apache.paimon.table.TableTestBase;
import org.apache.paimon.table.source.ReadBuilder;
import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowKind;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -153,6 +154,46 @@ public class PartitionsTableTest extends TableTestBase {
assertThat(result).containsExactlyInAnyOrderElementsOf(expectedRow);
}
+ @Test
+ public void testPartitionDeletionNumWithoutDeletionVectors() throws
Exception {
+ PredicateBuilder builder = new
PredicateBuilder(PartitionsTable.TABLE_TYPE);
+
+ List<InternalRow> expectedRow = new ArrayList<>();
+ expectedRow.add(GenericRow.of(BinaryString.fromString("pt=1"), 0L));
+ expectedRow.add(GenericRow.of(BinaryString.fromString("pt=2"), 0L));
+ expectedRow.add(GenericRow.of(BinaryString.fromString("pt=3"), 0L));
+
+ assertThat(read(partitionsTable, new int[] {0, 11}))
+ .containsExactlyInAnyOrderElementsOf(expectedRow);
+ assertThat(
+ readProjectedPartitions(
+ partitionsTable, builder.greaterThan(11, 0L),
new int[] {0}))
+ .isEmpty();
+ assertThat(readProjectedPartitions(partitionsTable, builder.equal(11,
0L), new int[] {0}))
+ .containsExactlyInAnyOrder("pt=1", "pt=2", "pt=3");
+ }
+
+ @Test
+ public void testPartitionDeletionNumWithDeletionVectors() throws Exception
{
+ PartitionsTable deletionVectorPartitionsTable =
createDeletionVectorPartitionsTable();
+ PredicateBuilder builder = new
PredicateBuilder(PartitionsTable.TABLE_TYPE);
+
+ assertThat(readPartitionAndDeletionNum(deletionVectorPartitionsTable,
null))
+ .containsExactlyInAnyOrder("pt=1-2", "pt=2-1", "pt=3-0");
+ assertThat(
+ readProjectedPartitions(
+ deletionVectorPartitionsTable,
+ builder.greaterThan(11, 0L),
+ new int[] {0}))
+ .containsExactlyInAnyOrder("pt=1", "pt=2");
+ assertThat(
+ readProjectedPartitions(
+ deletionVectorPartitionsTable,
+ builder.equal(11, 0L),
+ new int[] {0}))
+ .containsExactlyInAnyOrder("pt=3");
+ }
+
@Test
void testPartitionWithLegacyPartitionName() throws Exception {
String testTableName = "TestLegacyTable";
@@ -246,4 +287,68 @@ public class PartitionsTableTest extends TableTestBase {
}
return rows;
}
+
+ private PartitionsTable createDeletionVectorPartitionsTable() throws
Exception {
+ String testTableName = "DeletionVectorTable";
+ Schema testSchema =
+ Schema.newBuilder()
+ .column("pk", DataTypes.INT())
+ .column("pt", DataTypes.INT())
+ .column("col1", DataTypes.INT())
+ .partitionKeys("pt")
+ .primaryKey("pk", "pt")
+ .option(CoreOptions.CHANGELOG_PRODUCER.key(), "input")
+ .option(CoreOptions.DELETION_VECTORS_ENABLED.key(),
"true")
+ .option("bucket", "1")
+ .build();
+
+ Identifier testTableId = identifier(testTableName);
+ catalog.createTable(testTableId, testSchema, true);
+ FileStoreTable deletionVectorTable = (FileStoreTable)
catalog.getTable(testTableId);
+
+ write(
+ deletionVectorTable,
+ ioManager,
+ GenericRow.of(1, 1, 1),
+ GenericRow.of(2, 1, 2),
+ GenericRow.of(3, 2, 3),
+ GenericRow.of(5, 1, 5),
+ GenericRow.of(6, 2, 6),
+ GenericRow.of(4, 3, 4));
+ write(
+ deletionVectorTable,
+ ioManager,
+ GenericRow.ofKind(RowKind.DELETE, 1, 1, 1),
+ GenericRow.ofKind(RowKind.DELETE, 2, 1, 2),
+ GenericRow.ofKind(RowKind.DELETE, 3, 2, 3));
+
+ Identifier partitionsTableId =
+ identifier(testTableName + SYSTEM_TABLE_SPLITTER +
PartitionsTable.PARTITIONS);
+ return (PartitionsTable) catalog.getTable(partitionsTableId);
+ }
+
+ private List<String> readProjectedPartitions(
+ PartitionsTable table, Predicate predicate, int[] projection)
throws IOException {
+ ReadBuilder readBuilder = table.newReadBuilder().withFilter(predicate);
+ readBuilder.withProjection(projection);
+ List<String> rows = new ArrayList<>();
+ try (RecordReader<InternalRow> reader =
+
readBuilder.newRead().createReader(readBuilder.newScan().plan())) {
+ reader.forEachRemaining(row ->
rows.add(row.getString(0).toString()));
+ }
+ return rows;
+ }
+
+ private List<String> readPartitionAndDeletionNum(PartitionsTable table,
Predicate predicate)
+ throws IOException {
+ ReadBuilder readBuilder = table.newReadBuilder().withFilter(predicate);
+ readBuilder.withProjection(new int[] {0, 11});
+ List<String> rows = new ArrayList<>();
+ try (RecordReader<InternalRow> reader =
+
readBuilder.newRead().createReader(readBuilder.newScan().plan())) {
+ reader.forEachRemaining(
+ row -> rows.add(row.getString(0).toString() + "-" +
row.getLong(1)));
+ }
+ return rows;
+ }
}
diff --git a/paimon-python/pypaimon/table/system/partitions_table.py
b/paimon-python/pypaimon/table/system/partitions_table.py
index 583e4fd94f..70685fee0b 100644
--- a/paimon-python/pypaimon/table/system/partitions_table.py
+++ b/paimon-python/pypaimon/table/system/partitions_table.py
@@ -21,6 +21,8 @@ from typing import List, Optional
import pyarrow
+from pypaimon.index.index_file_handler import IndexFileHandler
+from pypaimon.manifest.index_manifest_file import IndexManifestFile
from pypaimon.manifest.manifest_file_manager import ManifestFileManager
from pypaimon.manifest.manifest_list_manager import ManifestListManager
from pypaimon.schema.data_types import AtomicType, DataField, RowType
@@ -39,6 +41,7 @@ TABLE_TYPE = RowType(False, [
DataField(8, "options", AtomicType("STRING", nullable=True)),
DataField(9, "total_buckets", AtomicType("INT", nullable=False)),
DataField(10, "done", AtomicType("BOOLEAN", nullable=False)),
+ DataField(11, "deleted_record_count", AtomicType("BIGINT", nullable=True)),
])
@@ -73,9 +76,11 @@ class PartitionsTable(SystemTable):
manifest_file_manager = ManifestFileManager(self.base_table)
entries = manifest_file_manager.read_entries_parallel(
manifest_files, drop_stats=True)
+ deleted_record_counts = self._deleted_record_counts(snapshot)
partition_map: dict = {}
for entry in entries:
+ partition_key = tuple(entry.partition.values)
spec_items = tuple(
(field.name, str(value))
for field, value in zip(
@@ -86,6 +91,7 @@ class PartitionsTable(SystemTable):
if stats is None:
stats = {
"spec_items": spec_items,
+ "partition_key": partition_key,
"record_count": 0,
"file_size_in_bytes": 0,
"file_count": 0,
@@ -110,6 +116,7 @@ class PartitionsTable(SystemTable):
file_counts: List[int] = []
last_update_times: List[Optional[int]] = []
total_buckets: List[int] = []
+ deleted_counts: List[Optional[int]] = []
for stats in partition_map.values():
partition_strings.append(_render_partition(stats["spec_items"]))
@@ -118,6 +125,8 @@ class PartitionsTable(SystemTable):
file_counts.append(stats["file_count"])
last_update_times.append(stats["last_update_time"])
total_buckets.append(len(stats["buckets"]))
+ deleted_counts.append(
+ deleted_record_counts.get(stats["partition_key"], 0))
n = len(partition_map)
return pyarrow.table({
@@ -136,8 +145,40 @@ class PartitionsTable(SystemTable):
"options": pyarrow.array([None] * n, type=pyarrow.string()),
"total_buckets": pyarrow.array(total_buckets,
type=pyarrow.int32()),
"done": pyarrow.array([False] * n, type=pyarrow.bool_()),
+ "deleted_record_count": pyarrow.array(
+ deleted_counts, type=pyarrow.int64()),
})
+ def _deleted_record_counts(self, snapshot) -> dict:
+ if not self.base_table.options.deletion_vectors_enabled(False):
+ return {}
+
+ entries = IndexFileHandler(self.base_table).scan(
+ snapshot,
+ lambda entry: entry.index_file.index_type
+ == IndexManifestFile.DELETION_VECTORS_INDEX,
+ )
+ result = {}
+ for entry in entries:
+ partition_key = tuple(entry.partition.values)
+ if partition_key in result and result[partition_key] is None:
+ continue
+
+ dv_ranges = entry.index_file.dv_ranges
+ if not dv_ranges:
+ continue
+
+ count = result.get(partition_key, 0)
+ for dv_meta in dv_ranges.values():
+ if dv_meta.cardinality is None:
+ result[partition_key] = None
+ break
+ count += int(dv_meta.cardinality)
+ else:
+ result[partition_key] = count
+
+ return result
+
@staticmethod
def _empty_table() -> pyarrow.Table:
return pyarrow.table({
@@ -152,6 +193,7 @@ class PartitionsTable(SystemTable):
"options": pyarrow.array([], type=pyarrow.string()),
"total_buckets": pyarrow.array([], type=pyarrow.int32()),
"done": pyarrow.array([], type=pyarrow.bool_()),
+ "deleted_record_count": pyarrow.array([], type=pyarrow.int64()),
})
diff --git a/paimon-python/pypaimon/tests/system/partitions_table_test.py
b/paimon-python/pypaimon/tests/system/partitions_table_test.py
index 8f07fd5f2a..108cc6d358 100644
--- a/paimon-python/pypaimon/tests/system/partitions_table_test.py
+++ b/paimon-python/pypaimon/tests/system/partitions_table_test.py
@@ -21,11 +21,17 @@ import os
import shutil
import tempfile
import unittest
+from unittest.mock import patch
import pyarrow as pa
from pypaimon import CatalogFactory, Schema
+from pypaimon.index.deletion_vector_meta import DeletionVectorMeta
+from pypaimon.index.index_file_meta import IndexFileMeta
+from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
+from pypaimon.manifest.index_manifest_file import IndexManifestFile
from pypaimon.schema.data_types import DataField
+from pypaimon.table.row.generic_row import GenericRow
from pypaimon.table.system.partitions_table import PartitionsTable
@@ -86,7 +92,7 @@ class PartitionsTableTest(unittest.TestCase):
("last_update_time", True), ("created_at", True),
("created_by", True), ("updated_by", True),
("options", True), ("total_buckets", False),
- ("done", False),
+ ("done", False), ("deleted_record_count", True),
]
self.assertEqual([n for n, _ in expected],
[f.name for f in row_type.fields])
@@ -122,11 +128,70 @@ class PartitionsTableTest(unittest.TestCase):
# FileSystem catalog does not maintain a "done" flag.
for done in arrow_table.column("done").to_pylist():
self.assertFalse(done)
+ for count in arrow_table.column("deleted_record_count").to_pylist():
+ self.assertEqual(0, count)
# Catalog-managed fields are not surfaced by the filesystem path.
for field in ("created_by", "updated_by", "options", "created_at"):
for value in arrow_table.column(field).to_pylist():
self.assertIsNone(value)
+ def test_aggregates_deleted_record_count(self):
+ self._create_partitioned_table()
+ self._write_two_partitions()
+
+ table = self.catalog.get_table("db.t$partitions")
+ partition_fields = table.base_table.partition_keys_fields
+ entries = [
+ IndexManifestEntry(
+ kind=0,
+ partition=GenericRow(["2024-01-01"], partition_fields),
+ bucket=0,
+ index_file=IndexFileMeta(
+ IndexManifestFile.DELETION_VECTORS_INDEX,
+ "dv-1",
+ 1,
+ 2,
+ dv_ranges={
+ "file-1": DeletionVectorMeta("file-1", 0, 1, 1),
+ "file-2": DeletionVectorMeta("file-2", 1, 1, 2),
+ },
+ ),
+ ),
+ IndexManifestEntry(
+ kind=0,
+ partition=GenericRow(["2024-01-02"], partition_fields),
+ bucket=0,
+ index_file=IndexFileMeta(
+ IndexManifestFile.DELETION_VECTORS_INDEX,
+ "dv-2",
+ 1,
+ 1,
+ dv_ranges={
+ "legacy-file": DeletionVectorMeta(
+ "legacy-file", 0, 1, None),
+ },
+ ),
+ ),
+ ]
+
+ with patch.object(
+ table.base_table.options,
+ "deletion_vectors_enabled",
+ return_value=True,
+ ), patch(
+ "pypaimon.table.system.partitions_table.IndexFileHandler.scan",
+ return_value=entries,
+ ):
+ arrow_table = _read(table)
+
+ partitions = arrow_table.column("partition").to_pylist()
+ deleted_counts = dict(zip(
+ partitions,
+ arrow_table.column("deleted_record_count").to_pylist(),
+ ))
+ self.assertEqual(3, deleted_counts["dt=2024-01-01"])
+ self.assertIsNone(deleted_counts["dt=2024-01-02"])
+
if __name__ == "__main__":
unittest.main()