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 0abb9c3c0c [python] Add global index lifecycle APIs (#8393)
0abb9c3c0c is described below
commit 0abb9c3c0c42af527b107506d1f496a8ef66cfb9
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Jun 30 22:03:55 2026 +0800
[python] Add global index lifecycle APIs (#8393)
Add PyPaimon support for building and dropping BTree global indexes, and
expose the metadata needed to inspect global index coverage from Python.
---
docs/docs/multimodal-table/global-index.mdx | 239 ++++++++++++-
.../pypaimon/common/options/core_options.py | 53 +++
paimon-python/pypaimon/globalindex/__init__.py | 12 +
.../pypaimon/globalindex/btree/__init__.py | 2 +
.../globalindex/btree/btree_index_writer.py | 310 +++++++++++++++++
.../pypaimon/globalindex/create_global_index.py | 386 +++++++++++++++++++++
.../pypaimon/globalindex/drop_global_index.py | 177 ++++++++++
.../pypaimon/globalindex/key_serializer.py | 323 ++++++++++++++++-
.../{btree/__init__.py => result_entry.py} | 22 +-
.../pypaimon/globalindex/sorted_index_file_meta.py | 23 ++
.../pypaimon/manifest/index_manifest_file.py | 81 ++++-
paimon-python/pypaimon/table/file_store_table.py | 27 ++
.../pypaimon/table/system/file_key_ranges_table.py | 145 ++++++++
.../pypaimon/table/system/system_table_loader.py | 9 +-
.../pypaimon/table/system/table_indexes_table.py | 169 +++++++++
.../pypaimon/tests/global_index_build_test.py | 292 ++++++++++++++++
.../tests/system/system_table_loader_test.py | 4 +-
.../pypaimon/utils/file_store_path_factory.py | 32 +-
paimon-python/pypaimon/write/commit_message.py | 8 +-
paimon-python/pypaimon/write/file_store_commit.py | 33 +-
20 files changed, 2304 insertions(+), 43 deletions(-)
diff --git a/docs/docs/multimodal-table/global-index.mdx
b/docs/docs/multimodal-table/global-index.mdx
index 1cecce7993..a163390e0b 100644
--- a/docs/docs/multimodal-table/global-index.mdx
+++ b/docs/docs/multimodal-table/global-index.mdx
@@ -3,6 +3,9 @@ title: "Global Index"
sidebar_position: 8
---
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
@@ -55,6 +58,10 @@ Global indexes work on top of Data Evolution tables. To use
global indexes, your
Create a table with the required properties:
+<Tabs groupId="global-index-create-table">
+
+<TabItem value="sql" label="SQL">
+
```sql
CREATE TABLE my_table (
id INT,
@@ -69,10 +76,44 @@ CREATE TABLE my_table (
);
```
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+import pyarrow as pa
+
+from pypaimon import Schema
+
+schema = Schema.from_pyarrow_schema(
+ pa.schema([
+ pa.field("id", pa.int32()),
+ pa.field("name", pa.string()),
+ pa.field("embedding", pa.list_(pa.float32())),
+ pa.field("content", pa.string()),
+ ]),
+ options={
+ "bucket": "-1",
+ "row-tracking.enabled": "true",
+ "data-evolution.enabled": "true",
+ "global-index.enabled": "true",
+ },
+)
+
+catalog.create_table("db.my_table", schema, ignore_if_exists=False)
+```
+
+</TabItem>
+
+</Tabs>
+
## Lifecycle
-Create global indexes with the `create_global_index` procedure. You can build
an index for all
-partitions or only selected partitions:
+Create global indexes for all partitions or only selected partitions:
+
+<Tabs groupId="global-index-build">
+
+<TabItem value="sql" label="SQL">
```sql
CALL sys.create_global_index(
@@ -89,7 +130,41 @@ CALL sys.create_global_index(
);
```
-Drop index files with `drop_global_index`:
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+table = catalog.get_table("db.my_table")
+
+added_files = table.create_global_index("name")
+print(added_files)
+```
+
+The API returns the number of committed index files. You can pass build
options and restrict the
+build to selected partitions:
+
+```python
+added_files = table.create_global_index(
+ "name",
+ index_type="btree",
+ partitions=[{"dt": "2026-06-18"}, {"dt": "2026-06-19"}],
+ options={"sorted-index.records-per-range": "10000000"},
+)
+```
+
+</TabItem>
+
+</Tabs>
+
+PyPaimon global index build currently supports single-column BTree indexes on
+tables with row tracking enabled.
+
+Drop index files:
+
+<Tabs groupId="global-index-drop">
+
+<TabItem value="sql" label="SQL">
```sql
CALL sys.drop_global_index(
@@ -99,25 +174,111 @@ CALL sys.drop_global_index(
);
```
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+table = catalog.get_table("db.my_table")
+
+dropped_files = table.drop_global_index("name", index_type="btree")
+print(dropped_files)
+```
+
+You can also restrict the drop to selected partitions, or count matched files
without committing:
+
+```python
+matched_files = table.drop_global_index(
+ "name",
+ index_type="btree",
+ partitions=[{"dt": "2026-06-18"}, {"dt": "2026-06-19"}],
+ dry_run=True,
+)
+```
+
+</TabItem>
+
+</Tabs>
+
Global indexes are stored in index files and recorded in table metadata. To
inspect index files and
their row-id coverage, query the `table_indexes` system table:
+<Tabs groupId="global-index-table-indexes">
+
+<TabItem value="sql" label="SQL">
+
```sql
SELECT index_type, index_field_name, row_range_start, row_range_end
FROM my_table$table_indexes
WHERE index_field_name IS NOT NULL;
```
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+import pyarrow.compute as pc
+
+table_indexes = catalog.get_table("db.my_table$table_indexes")
+read_builder = table_indexes.new_read_builder().with_projection([
+ "index_type",
+ "index_field_name",
+ "row_range_start",
+ "row_range_end",
+])
+
+pa_table = read_builder.new_read().to_arrow(
+ read_builder.new_scan().plan().splits()
+)
+pa_table = pa_table.filter(pc.is_valid(pa_table["index_field_name"]))
+print(pa_table)
+```
+
+</TabItem>
+
+</Tabs>
+
You can also query `file_key_ranges` to inspect data file row-id ranges and
diagnose coverage:
+<Tabs groupId="global-index-file-key-ranges">
+
+<TabItem value="sql" label="SQL">
+
```sql
SELECT file_path, first_row_id, record_count
FROM my_table$file_key_ranges;
```
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+file_key_ranges = catalog.get_table("db.my_table$file_key_ranges")
+read_builder = file_key_ranges.new_read_builder().with_projection([
+ "file_path",
+ "first_row_id",
+ "record_count",
+])
+
+pa_table = read_builder.new_read().to_arrow(
+ read_builder.new_scan().plan().splits()
+)
+print(pa_table)
+```
+
+</TabItem>
+
+</Tabs>
+
For workloads that need newly appended rows to become visible only after
existing global indexes
cover them, enable the visibility callback:
+<Tabs groupId="global-index-visibility-callback">
+
+<TabItem value="sql" label="SQL">
+
```sql
ALTER TABLE my_table SET (
'visibility-callback.enabled' = 'true',
@@ -125,19 +286,66 @@ ALTER TABLE my_table SET (
);
```
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+from pypaimon.schema.schema_change import SchemaChange
+
+catalog.alter_table(
+ "db.my_table",
+ [
+ SchemaChange.set_option("visibility-callback.enabled", "true"),
+ SchemaChange.set_option("visibility-callback.timeout", "30 min"),
+ ],
+)
+```
+
+</TabItem>
+
+</Tabs>
+
## Coverage and Freshness
Global index files cover row-id ranges. If more rows are appended after an
index is built, those
-new rows are not automatically covered by the existing index files. Run
`create_global_index` again
-to build index files for newly uncovered data. By default, queries use fast
search and only read
+new rows are not automatically covered by the existing index files. Build the
global index again
+to create index files for newly uncovered data. By default, queries use fast
search and only read
indexed row ranges; rows in uncovered ranges are not returned for that indexed
query.
To improve freshness for query types that support raw-data search, set:
+<Tabs groupId="global-index-search-mode">
+
+<TabItem value="sql" label="SQL">
+
```sql
ALTER TABLE my_table SET ('global-index.search-mode' = 'full');
```
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+from pypaimon.schema.schema_change import SchemaChange
+
+catalog.alter_table(
+ "db.my_table",
+ [SchemaChange.set_option("global-index.search-mode", "full")],
+)
+```
+
+For a read-only override on an existing `Table` instance:
+
+```python
+full_table = table.copy({"global-index.search-mode": "full"})
+```
+
+</TabItem>
+
+</Tabs>
+
With `full` search, supported global-index queries first use the snapshot
`nextRowId` and global
index row-id coverage to detect whether any row range is missing from the
index. Raw data is scanned
only when such a gap exists. Use `detail` search when data files may have been
rewritten or updated
@@ -146,10 +354,31 @@ handle index invalidation caused by updates or rewrites.
To temporarily disable global-index scan acceleration while keeping the index
files, set:
+<Tabs groupId="global-index-enabled">
+
+<TabItem value="sql" label="SQL">
+
```sql
ALTER TABLE my_table SET ('global-index.enabled' = 'false');
```
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+from pypaimon.schema.schema_change import SchemaChange
+
+catalog.alter_table(
+ "db.my_table",
+ [SchemaChange.set_option("global-index.enabled", "false")],
+)
+```
+
+</TabItem>
+
+</Tabs>
+
Set it back to `true` to use global indexes during scans again.
## Build Options
diff --git a/paimon-python/pypaimon/common/options/core_options.py
b/paimon-python/pypaimon/common/options/core_options.py
index 56e323c997..9ae32c187b 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -20,6 +20,7 @@ import warnings
from datetime import timedelta
from enum import Enum
from typing import Dict, List, Optional
+from urllib.parse import urlparse
from pypaimon.common.memory_size import MemorySize
from pypaimon.common.options import Options
@@ -646,6 +647,16 @@ class CoreOptions:
)
)
+ GLOBAL_INDEX_EXTERNAL_PATH: ConfigOption[str] = (
+ ConfigOptions.key("global-index.external-path")
+ .string_type()
+ .no_default_value()
+ .with_description(
+ "Global index root directory. If not set, global index files are "
+ "stored under the table index directory."
+ )
+ )
+
GLOBAL_INDEX_THREAD_NUM: ConfigOption[int] = (
ConfigOptions.key("global-index.thread-num")
.int_type()
@@ -675,6 +686,29 @@ class CoreOptions:
)
)
+ BTREE_INDEX_BLOCK_SIZE: ConfigOption[MemorySize] = (
+ ConfigOptions.key("btree-index.block-size")
+ .memory_type()
+ .default_value(MemorySize.of_kibi_bytes(64))
+ .with_description("The block size to use for BTree global indexes.")
+ )
+
+ SORTED_INDEX_RECORDS_PER_RANGE: ConfigOption[int] = (
+ ConfigOptions.key("sorted-index.records-per-range")
+ .long_type()
+ .default_value(10_000_000)
+ .with_description("The expected number of records per sorted global
index file.")
+ )
+
+ BTREE_INDEX_RECORDS_PER_RANGE: ConfigOption[int] = (
+ ConfigOptions.key("btree-index.records-per-range")
+ .long_type()
+ .default_value(10_000_000)
+ .with_description(
+ "The expected number of records per BTree global index file."
+ )
+ )
+
BITMAP_INDEX_FALLBACK_SCAN_MAX_SIZE: ConfigOption[MemorySize] = (
ConfigOptions.key("bitmap-index.fallback-scan-max-size")
.memory_type()
@@ -1149,6 +1183,17 @@ class CoreOptions:
def global_index_search_mode(self):
return self.options.get(CoreOptions.GLOBAL_INDEX_SEARCH_MODE)
+ def global_index_external_path(self, default=None):
+ value = self.options.get(CoreOptions.GLOBAL_INDEX_EXTERNAL_PATH,
default)
+ if value is None:
+ return None
+ value = str(value).strip()
+ if not value:
+ return None
+ if not urlparse(value).scheme:
+ raise ValueError("scheme should not be null: %s" % value)
+ return value
+
def global_index_thread_num(self) -> Optional[int]:
return self.options.get(CoreOptions.GLOBAL_INDEX_THREAD_NUM)
@@ -1157,6 +1202,14 @@ class CoreOptions:
CoreOptions.BTREE_INDEX_FALLBACK_SCAN_MAX_SIZE
).get_bytes()
+ def btree_index_block_size(self) -> int:
+ return self.options.get(CoreOptions.BTREE_INDEX_BLOCK_SIZE).get_bytes()
+
+ def sorted_index_records_per_range(self) -> int:
+ if self.options.contains(CoreOptions.SORTED_INDEX_RECORDS_PER_RANGE):
+ return self.options.get(CoreOptions.SORTED_INDEX_RECORDS_PER_RANGE)
+ return self.options.get(CoreOptions.BTREE_INDEX_RECORDS_PER_RANGE)
+
def bitmap_index_fallback_scan_max_size(self) -> int:
return self.options.get(
CoreOptions.BITMAP_INDEX_FALLBACK_SCAN_MAX_SIZE
diff --git a/paimon-python/pypaimon/globalindex/__init__.py
b/paimon-python/pypaimon/globalindex/__init__.py
index 35c043f577..54b8c65f86 100644
--- a/paimon-python/pypaimon/globalindex/__init__.py
+++ b/paimon-python/pypaimon/globalindex/__init__.py
@@ -44,6 +44,14 @@ from pypaimon.globalindex.offset_global_index_reader import
OffsetGlobalIndexRea
from pypaimon.globalindex.sorted_file_global_index_reader import
SortedFileGlobalIndexReader
from pypaimon.globalindex.sorted_file_meta_selector import
SortedFileMetaSelector
from pypaimon.globalindex.sorted_index_file_meta import SortedIndexFileMeta
+from pypaimon.globalindex.create_global_index import (
+ GlobalIndexBuilder,
+ create_global_index,
+)
+from pypaimon.globalindex.drop_global_index import (
+ GlobalIndexDropper,
+ drop_global_index,
+)
from pypaimon.utils.range import Range
__all__ = [
@@ -74,5 +82,9 @@ __all__ = [
'SortedFileGlobalIndexReader',
'SortedFileMetaSelector',
'SortedIndexFileMeta',
+ 'GlobalIndexBuilder',
+ 'create_global_index',
+ 'GlobalIndexDropper',
+ 'drop_global_index',
'Range',
]
diff --git a/paimon-python/pypaimon/globalindex/btree/__init__.py
b/paimon-python/pypaimon/globalindex/btree/__init__.py
index 17a6a30cbc..62720f380d 100644
--- a/paimon-python/pypaimon/globalindex/btree/__init__.py
+++ b/paimon-python/pypaimon/globalindex/btree/__init__.py
@@ -20,11 +20,13 @@
from pypaimon.globalindex.btree.btree_index_reader import BTreeIndexReader
from pypaimon.globalindex.btree.btree_index_meta import BTreeIndexMeta
from pypaimon.globalindex.btree.btree_file_meta_selector import
BTreeFileMetaSelector
+from pypaimon.globalindex.btree.btree_index_writer import BTreeIndexWriter
from pypaimon.globalindex.btree.key_serializer import KeySerializer
from pypaimon.globalindex.btree.lazy_filtered_btree_reader import
LazyFilteredBTreeReader
__all__ = [
'BTreeIndexReader',
+ 'BTreeIndexWriter',
'BTreeIndexMeta',
'BTreeFileMetaSelector',
'KeySerializer',
diff --git a/paimon-python/pypaimon/globalindex/btree/btree_index_writer.py
b/paimon-python/pypaimon/globalindex/btree/btree_index_writer.py
new file mode 100644
index 0000000000..85be3fb2ee
--- /dev/null
+++ b/paimon-python/pypaimon/globalindex/btree/btree_index_writer.py
@@ -0,0 +1,310 @@
+# 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.
+
+"""BTree global index writer compatible with Java's SST-backed format."""
+
+import struct
+import uuid
+import zlib
+from typing import List, Optional
+
+from pypaimon.globalindex.block_compression import (
+ COMPRESSION_NONE,
+ crc32c,
+)
+from pypaimon.globalindex.result_entry import ResultEntry
+from pypaimon.globalindex.sorted_index_file_meta import SortedIndexFileMeta
+from pypaimon.utils.roaring_bitmap import RoaringBitmap64
+
+
+BTREE_IDENTIFIER = "btree"
+_BTREE_FOOTER_LENGTH = 52
+_BTREE_MAGIC_NUMBER = 0x50425449
+_BTREE_CURRENT_VERSION = 1
+_BLOCK_ALIGNED = 0
+_BLOCK_UNALIGNED = 1
+
+
+def _write_var_len_int(value: int) -> bytes:
+ if value < 0:
+ raise ValueError("negative value: v=%s" % value)
+ result = bytearray()
+ while value & ~0x7F:
+ result.append((value & 0x7F) | 0x80)
+ value >>= 7
+ result.append(value)
+ return bytes(result)
+
+
+def _write_var_len_long(value: int) -> bytes:
+ if value < 0:
+ raise ValueError("negative value: v=%s" % value)
+ result = bytearray()
+ while value & ~0x7F:
+ result.append((value & 0x7F) | 0x80)
+ value >>= 7
+ result.append(value)
+ return bytes(result)
+
+
+def _write_block_handle(offset: int, size: int) -> bytes:
+ return _write_var_len_long(offset) + _write_var_len_int(size)
+
+
+class _PositionOutput:
+ def __init__(self, output_stream):
+ self._output_stream = output_stream
+ self._pos = 0
+
+ @property
+ def pos(self) -> int:
+ return self._pos
+
+ def write(self, data: bytes) -> None:
+ self._output_stream.write(data)
+ self._pos += len(data)
+
+ def close(self) -> None:
+ self._output_stream.close()
+
+
+class _BlockWriter:
+ def __init__(self):
+ self._positions: List[int] = []
+ self._block = bytearray()
+ self._aligned_size = 0
+ self._aligned = True
+
+ def reset(self) -> None:
+ self._positions = []
+ self._block = bytearray()
+ self._aligned_size = 0
+ self._aligned = True
+
+ def add(self, key: bytes, value: bytes) -> None:
+ start_position = len(self._block)
+ self._block.extend(_write_var_len_int(len(key)))
+ self._block.extend(key)
+ self._block.extend(_write_var_len_int(len(value)))
+ self._block.extend(value)
+ end_position = len(self._block)
+
+ self._positions.append(start_position)
+ if self._aligned:
+ current_size = end_position - start_position
+ if self._aligned_size == 0:
+ self._aligned_size = current_size
+ else:
+ self._aligned = self._aligned_size == current_size
+
+ def size(self) -> int:
+ return len(self._positions)
+
+ def memory(self) -> int:
+ size = len(self._block) + 5
+ if not self._aligned:
+ size += len(self._positions) * 4
+ return size
+
+ def finish(self) -> bytes:
+ if not self._positions:
+ self._aligned = False
+
+ result = bytearray(self._block)
+ if self._aligned:
+ result.extend(struct.pack('<I', self._aligned_size))
+ result.append(_BLOCK_ALIGNED)
+ else:
+ for position in self._positions:
+ result.extend(struct.pack('<I', position))
+ result.extend(struct.pack('<I', len(self._positions)))
+ result.append(_BLOCK_UNALIGNED)
+ return bytes(result)
+
+
+class _SstFileWriter:
+ def __init__(self, out: _PositionOutput, block_size: int):
+ self._out = out
+ self._block_size = block_size
+ self._data_block_writer = _BlockWriter()
+ self._index_block_writer = _BlockWriter()
+ self._last_key: Optional[bytes] = None
+
+ def put(self, key: bytes, value: bytes) -> None:
+ self._data_block_writer.add(key, value)
+ self._last_key = key
+
+ if self._data_block_writer.memory() > self._block_size:
+ self.flush()
+
+ def flush(self) -> None:
+ if self._data_block_writer.size() == 0:
+ return
+ block_offset, block_size = self._write_block(self._data_block_writer)
+ self._index_block_writer.add(
+ self._last_key, _write_block_handle(block_offset, block_size))
+
+ def write_bloom_filter(self):
+ return None
+
+ def write_index_block(self):
+ return self._write_block(self._index_block_writer)
+
+ def write_slice(self, data: bytes) -> None:
+ self._out.write(data)
+
+ def _write_block(self, block_writer: _BlockWriter):
+ block = block_writer.finish()
+ block_offset = self._out.pos
+ self._out.write(block)
+ self._out.write(
+ struct.pack('<B', COMPRESSION_NONE)
+ + struct.pack('<I', crc32c(block, COMPRESSION_NONE)))
+ block_writer.reset()
+ return block_offset, len(block)
+
+
+class BTreeIndexWriter:
+ """Writer for one BTree global index file.
+
+ Keys must be written in non-decreasing order, matching Java's writer
+ contract. Row IDs are local to the manifest row range.
+ """
+
+ def __init__(
+ self,
+ file_io,
+ index_path: str,
+ key_serializer,
+ block_size: int = 64 * 1024,
+ ):
+ self.file_name = (
+ "%s-global-index-%s.index" % (BTREE_IDENTIFIER, uuid.uuid4()))
+ self._file_io = file_io
+ self._index_path = index_path.rstrip('/')
+ self._key_serializer = key_serializer
+ self._comparator = key_serializer.create_comparator()
+ self._current_row_ids: List[int] = []
+ self._null_bitmap: Optional[RoaringBitmap64] = None
+ self._first_key = None
+ self._last_key = None
+ self._row_count = 0
+ self._closed = False
+
+ self._file_io.check_or_mkdirs(self._index_path)
+ self._output = _PositionOutput(
+ self._file_io.new_output_stream(self._file_path()))
+ self._sst = _SstFileWriter(self._output, block_size)
+
+ def write(self, key, row_id: int) -> None:
+ self._row_count += 1
+ if key is None:
+ if self._null_bitmap is None:
+ self._null_bitmap = RoaringBitmap64()
+ self._null_bitmap.add(row_id)
+ return
+
+ if self._last_key is not None and self._comparator(key,
self._last_key) != 0:
+ self._flush()
+
+ self._last_key = key
+ self._current_row_ids.append(row_id)
+ if self._first_key is None:
+ self._first_key = key
+
+ def finish(self) -> List[ResultEntry]:
+ if self._closed:
+ raise RuntimeError("BTreeIndexWriter is already closed.")
+ self._closed = True
+
+ try:
+ self._flush()
+ self._sst.flush()
+ null_bitmap_handle = self._write_null_bitmap()
+ bloom_filter_handle = self._sst.write_bloom_filter()
+ index_block_handle = self._sst.write_index_block()
+ self._sst.write_slice(
+ _write_footer(
+ bloom_filter_handle, index_block_handle,
null_bitmap_handle))
+ self._output.close()
+ except Exception:
+ self._file_io.delete_quietly(self._file_path())
+ raise
+
+ if self._first_key is None and self._null_bitmap is None:
+ raise RuntimeError("Should never write an empty btree index file.")
+
+ meta = SortedIndexFileMeta(
+ None if self._first_key is None
+ else self._key_serializer.serialize(self._first_key),
+ None if self._last_key is None
+ else self._key_serializer.serialize(self._last_key),
+ self._null_bitmap is not None,
+ ).serialize()
+ return [ResultEntry(self.file_name, self._row_count, meta)]
+
+ def _flush(self) -> None:
+ if not self._current_row_ids:
+ return
+
+ value = bytearray()
+ value.extend(_write_var_len_int(len(self._current_row_ids)))
+ for row_id in self._current_row_ids:
+ value.extend(_write_var_len_long(row_id))
+ self._current_row_ids = []
+ self._sst.put(self._key_serializer.serialize(self._last_key),
bytes(value))
+
+ def _write_null_bitmap(self):
+ if self._null_bitmap is None:
+ return None
+
+ serialized = self._null_bitmap.serialize()
+ offset = self._output.pos
+ self._output.write(serialized)
+ self._output.write(struct.pack('<I', zlib.crc32(serialized) &
0xFFFFFFFF))
+ return offset, len(serialized)
+
+ def _file_path(self) -> str:
+ return "%s/%s" % (self._index_path, self.file_name)
+
+
+def _write_footer(bloom_filter_handle, index_block_handle, null_bitmap_handle)
-> bytes:
+ result = bytearray()
+ if bloom_filter_handle is None:
+ result.extend(struct.pack('<QIQ', 0, 0, 0))
+ else:
+ result.extend(
+ struct.pack(
+ '<QIQ',
+ bloom_filter_handle[0],
+ bloom_filter_handle[1],
+ bloom_filter_handle[2],
+ ))
+
+ result.extend(struct.pack('<QI', index_block_handle[0],
index_block_handle[1]))
+
+ if null_bitmap_handle is None:
+ result.extend(struct.pack('<QI', 0, 0))
+ else:
+ result.extend(
+ struct.pack('<QI', null_bitmap_handle[0], null_bitmap_handle[1]))
+
+ result.extend(struct.pack('<I', _BTREE_CURRENT_VERSION))
+ result.extend(struct.pack('<I', _BTREE_MAGIC_NUMBER))
+ if len(result) != _BTREE_FOOTER_LENGTH:
+ raise AssertionError("Unexpected BTree footer length: %s" %
len(result))
+ return bytes(result)
diff --git a/paimon-python/pypaimon/globalindex/create_global_index.py
b/paimon-python/pypaimon/globalindex/create_global_index.py
new file mode 100644
index 0000000000..3aa3bb919f
--- /dev/null
+++ b/paimon-python/pypaimon/globalindex/create_global_index.py
@@ -0,0 +1,386 @@
+# 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.
+
+"""Build global index files from Python."""
+
+from functools import cmp_to_key
+from typing import Dict, List, Optional, Sequence, Union
+
+import pyarrow as pa
+
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.common.options.options import Options
+from pypaimon.common.predicate import Predicate
+from pypaimon.globalindex.btree.btree_index_writer import (
+ BTREE_IDENTIFIER,
+ BTreeIndexWriter,
+)
+from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
+from pypaimon.globalindex.key_serializer import create_serializer
+from pypaimon.index.index_file_meta import IndexFileMeta
+from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
+from pypaimon.read.split import DataSplit
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.table.special_fields import SpecialFields
+from pypaimon.utils.range import Range
+from pypaimon.utils.range_helper import RangeHelper
+from pypaimon.write.commit_message import CommitMessage
+
+
+def create_global_index(
+ table,
+ index_column: Union[str, Sequence[str]],
+ index_type: str = BTREE_IDENTIFIER,
+ partition_filter: Optional[Predicate] = None,
+ partitions: Optional[Union[Dict[str, object], Sequence[Dict[str,
object]]]] = None,
+ options: Optional[Dict[str, object]] = None,
+) -> int:
+ """Build and commit global index files for a table.
+
+ Returns the number of index files added to the table snapshot.
+ """
+
+ builder = GlobalIndexBuilder(
+ table,
+ index_column,
+ index_type=index_type,
+ partition_filter=partition_filter,
+ partitions=partitions,
+ options=options,
+ )
+ messages = builder.build()
+ if not messages:
+ return 0
+
+ write_builder = table.new_batch_write_builder()
+ commit = write_builder.new_commit()
+ try:
+ commit.commit(messages)
+ finally:
+ commit.close()
+ return sum(len(message.index_adds) for message in messages)
+
+
+class GlobalIndexBuilder:
+ """Small Python builder for sorted global indexes."""
+
+ def __init__(
+ self,
+ table,
+ index_column: Union[str, Sequence[str]],
+ index_type: str = BTREE_IDENTIFIER,
+ partition_filter: Optional[Predicate] = None,
+ partitions: Optional[Union[Dict[str, object], Sequence[Dict[str,
object]]]] = None,
+ options: Optional[Dict[str, object]] = None,
+ ):
+ self._table = table
+ self._index_columns = _normalize_index_columns(index_column)
+ self._index_type = index_type.lower().strip()
+ self._partition_filter = partition_filter
+ self._partitions = partitions
+ self._options = _merged_options(table, options)
+ self._core_options = CoreOptions(self._options)
+
+ if self._index_type != BTREE_IDENTIFIER:
+ raise ValueError(
+ "Python global index build currently supports only '%s', got
'%s'."
+ % (BTREE_IDENTIFIER, index_type)
+ )
+ if len(self._index_columns) != 1:
+ raise ValueError(
+ "Python '%s' global index build currently supports one column,
got %s."
+ % (BTREE_IDENTIFIER, self._index_columns)
+ )
+ if not self._table.options.row_tracking_enabled():
+ raise ValueError(
+ "Table '%s' must enable 'row-tracking.enabled=true' before "
+ "creating global index." % self._table.identifier
+ )
+ for column in self._index_columns:
+ if column not in self._table.field_dict:
+ raise ValueError(
+ "Column '%s' does not exist in table '%s'."
+ % (column, self._table.identifier)
+ )
+
+ def build(self) -> List[CommitMessage]:
+ read_builder = self._table.new_read_builder()
+ partition_filter = self._resolve_partition_filter(read_builder)
+ if partition_filter is not None:
+ read_builder = read_builder.with_partition_filter(partition_filter)
+
+ scan = read_builder.new_scan()
+ splits = scan.plan().splits()
+ if not splits:
+ return []
+
+ index_field = self._table.field_dict[self._index_columns[0]]
+ key_serializer = create_serializer(index_field.type)
+ block_size = self._core_options.btree_index_block_size()
+ records_per_range = self._core_options.sorted_index_records_per_range()
+ if records_per_range <= 0:
+ raise ValueError("sorted-index.records-per-range must be
positive.")
+
+ read_type = [index_field, SpecialFields.ROW_ID]
+ from pypaimon.read.table_read import TableRead
+
+ table_read = TableRead(
+ table=self._table,
+ predicate=None,
+ read_type=read_type,
+ )
+ index_path_factory =
self._table.path_factory().global_index_path_factory()
+ index_path = index_path_factory.global_index_root_path()
+
+ messages = []
+ for split in _split_by_contiguous_row_range(splits):
+ row_range = _calc_row_range(split)
+ table = table_read.to_arrow([split])
+ if table is None or table.num_rows == 0:
+ continue
+ rows = _extract_sorted_rows(
+ table,
+ self._index_columns[0],
+ SpecialFields.ROW_ID.name,
+ key_serializer,
+ )
+ if not rows:
+ continue
+ index_adds = []
+ for chunk in _chunks(rows, records_per_range):
+ writer = BTreeIndexWriter(
+ self._table.file_io,
+ index_path,
+ key_serializer,
+ block_size=block_size,
+ )
+ for key, row_id in chunk:
+ writer.write(key, row_id - row_range.from_)
+ result_entries = writer.finish()
+ index_adds.extend(
+ _to_index_manifest_entries(
+ self._table,
+ split.partition,
+ row_range,
+ index_field.id,
+ self._index_type,
+ result_entries,
+ )
+ )
+ if index_adds:
+ messages.append(
+ CommitMessage(
+ partition=tuple(split.partition.values),
+ bucket=0,
+ new_files=[],
+ index_adds=index_adds,
+ )
+ )
+ return messages
+
+ def _resolve_partition_filter(self, read_builder) -> Optional[Predicate]:
+ if self._partition_filter is not None:
+ return self._partition_filter
+ if self._partitions is None:
+ return None
+
+ partitions = self._partitions
+ if isinstance(partitions, dict):
+ partitions = [partitions]
+
+ predicate_builder = read_builder.new_predicate_builder()
+ partition_predicates = []
+ for partition in partitions:
+ sub_predicates = []
+ for key, value in partition.items():
+ if key not in self._table.partition_keys:
+ raise ValueError(
+ "Partition spec key '%s' is not a partition column. "
+ "Partition keys are: %s"
+ % (key, list(self._table.partition_keys))
+ )
+ if value is None:
+ sub_predicates.append(predicate_builder.is_null(key))
+ else:
+ sub_predicates.append(predicate_builder.equal(key, value))
+ if sub_predicates:
+ partition_predicates.append(
+ predicate_builder.and_predicates(sub_predicates))
+ return predicate_builder.or_predicates(partition_predicates)
+
+
+def _normalize_index_columns(index_column: Union[str, Sequence[str]]) ->
List[str]:
+ if isinstance(index_column, str):
+ return [c.strip() for c in index_column.split(",") if c.strip()]
+ return [str(c).strip() for c in index_column if str(c).strip()]
+
+
+def _merged_options(table, options: Optional[Dict[str, object]]) -> Options:
+ merged = dict(table.options.options.to_map())
+ if options:
+ merged.update(options)
+ return Options(merged)
+
+
+def _calc_row_range(split) -> Range:
+ ranges = []
+ for file in split.files:
+ row_range = file.row_id_range()
+ if row_range is None:
+ raise ValueError(
+ "Cannot build global index because file '%s' has no row id
range."
+ % file.file_name
+ )
+ ranges.append(row_range)
+ if not ranges:
+ raise ValueError("Cannot build global index for an empty split.")
+ merged = Range.sort_and_merge_overlap(ranges, True, True)
+ return Range(merged[0].from_, merged[-1].to)
+
+
+def _split_by_contiguous_row_range(splits):
+ result = []
+ for split in splits:
+ result.extend(_split_one_by_contiguous_row_range(split))
+ return result
+
+
+def _split_one_by_contiguous_row_range(split):
+ for file in split.files:
+ if file.row_id_range() is None:
+ raise ValueError(
+ "Cannot build global index because file '%s' has no row id
range."
+ % file.file_name
+ )
+
+ range_helper = RangeHelper(lambda file: file.row_id_range())
+ ranges = range_helper.merge_overlapping_ranges(split.files)
+ if not ranges:
+ return []
+
+ result = []
+ current_segment = []
+ current_max_row_id = None
+ for range_files in ranges:
+ min_row_id = min(file.row_id_range().from_ for file in range_files)
+ max_row_id = max(file.row_id_range().to for file in range_files)
+ if (
+ not current_segment
+ or current_max_row_id is None
+ or current_max_row_id >= min_row_id - 1
+ ):
+ current_segment.extend(range_files)
+ current_max_row_id = max_row_id
+ else:
+ result.append(_copy_split_with_files(split, current_segment))
+ current_segment = list(range_files)
+ current_max_row_id = max_row_id
+
+ if current_segment:
+ result.append(_copy_split_with_files(split, current_segment))
+ return result
+
+
+def _copy_split_with_files(split, files):
+ data_deletion_files = None
+ if getattr(split, "data_deletion_files", None) is not None:
+ index_by_file = {id(file): i for i, file in enumerate(split.files)}
+ data_deletion_files = [
+ split.data_deletion_files[index_by_file[id(file)]]
+ for file in files
+ ]
+ return DataSplit(
+ files=list(files),
+ partition=split.partition,
+ bucket=split.bucket,
+ raw_convertible=getattr(split, "raw_convertible", False),
+ data_deletion_files=data_deletion_files,
+ )
+
+
+def _extract_sorted_rows(
+ table: pa.Table,
+ index_column: str,
+ row_id_column: str,
+ key_serializer,
+):
+ keys = table.column(index_column).to_pylist()
+ row_ids = table.column(row_id_column).to_pylist()
+ rows = []
+ for key, row_id in zip(keys, row_ids):
+ if row_id is None:
+ raise ValueError("Cannot build global index because _ROW_ID is
null.")
+ rows.append((key, int(row_id)))
+
+ comparator = key_serializer.create_comparator()
+
+ def compare(left, right):
+ left_key = left[0]
+ right_key = right[0]
+ if left_key is None and right_key is None:
+ return 0
+ if left_key is None:
+ return -1
+ if right_key is None:
+ return 1
+ return comparator(left_key, right_key)
+
+ return sorted(rows, key=cmp_to_key(compare))
+
+
+def _chunks(rows, size):
+ for start in range(0, len(rows), size):
+ yield rows[start:start + size]
+
+
+def _to_index_manifest_entries(
+ table,
+ partition: GenericRow,
+ row_range: Range,
+ index_field_id: int,
+ index_type: str,
+ result_entries,
+) -> List[IndexManifestEntry]:
+ path_factory = table.path_factory().global_index_path_factory()
+ entries = []
+ for result in result_entries:
+ file_path = path_factory.to_path(result.file_name)
+ file_size = table.file_io.get_file_size(file_path)
+ external_path = file_path if path_factory.is_external_path() else None
+ index_file = IndexFileMeta(
+ index_type=index_type,
+ file_name=result.file_name,
+ file_size=file_size,
+ row_count=result.row_count,
+ global_index_meta=GlobalIndexMeta(
+ row_range_start=row_range.from_,
+ row_range_end=row_range.to,
+ index_field_id=index_field_id,
+ extra_field_ids=None,
+ index_meta=result.meta,
+ ),
+ external_path=external_path,
+ )
+ entries.append(
+ IndexManifestEntry(
+ kind=0,
+ partition=partition,
+ bucket=0,
+ index_file=index_file,
+ )
+ )
+ return entries
diff --git a/paimon-python/pypaimon/globalindex/drop_global_index.py
b/paimon-python/pypaimon/globalindex/drop_global_index.py
new file mode 100644
index 0000000000..6be45499e8
--- /dev/null
+++ b/paimon-python/pypaimon/globalindex/drop_global_index.py
@@ -0,0 +1,177 @@
+# 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.
+
+"""Drop global index files from Python."""
+
+from typing import Dict, List, Optional, Sequence, Union
+
+from pypaimon.common.predicate import Predicate
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.globalindex.btree.btree_index_writer import BTREE_IDENTIFIER
+from pypaimon.index.index_file_handler import IndexFileHandler
+from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
+from pypaimon.write.commit_message import CommitMessage
+
+
+def drop_global_index(
+ table,
+ index_column: Union[str, Sequence[str]],
+ index_type: str = BTREE_IDENTIFIER,
+ partition_filter: Optional[Predicate] = None,
+ partitions: Optional[Union[Dict[str, object], Sequence[Dict[str,
object]]]] = None,
+ dry_run: bool = False,
+) -> int:
+ """Drop global index metadata for a table.
+
+ Returns the number of matched index files. When ``dry_run`` is true, no
+ commit is created.
+ """
+
+ dropper = GlobalIndexDropper(
+ table,
+ index_column,
+ index_type=index_type,
+ partition_filter=partition_filter,
+ partitions=partitions,
+ )
+ messages = dropper.build()
+ dropped = sum(len(message.index_deletes) for message in messages)
+ if dry_run or not messages:
+ return dropped
+
+ write_builder = table.new_batch_write_builder()
+ commit = write_builder.new_commit()
+ try:
+ commit.commit(messages)
+ finally:
+ commit.close()
+ return dropped
+
+
+class GlobalIndexDropper:
+ """Small Python dropper for global index manifest entries."""
+
+ def __init__(
+ self,
+ table,
+ index_column: Union[str, Sequence[str]],
+ index_type: str = BTREE_IDENTIFIER,
+ partition_filter: Optional[Predicate] = None,
+ partitions: Optional[Union[Dict[str, object], Sequence[Dict[str,
object]]]] = None,
+ ):
+ self._table = table
+ self._index_columns = _normalize_index_columns(index_column)
+ self._index_type = index_type.lower().strip()
+ self._partition_filter = partition_filter
+ self._partitions = partitions
+
+ if not self._index_columns:
+ raise ValueError("At least one index column is required.")
+ for column in self._index_columns:
+ if column not in self._table.field_dict:
+ raise ValueError(
+ "Column '%s' does not exist in table '%s'."
+ % (column, self._table.identifier)
+ )
+
+ def build(self) -> List[CommitMessage]:
+ snapshot = self._table.snapshot_manager().get_latest_snapshot()
+ if snapshot is None:
+ return []
+
+ target_field_ids = [
+ self._table.field_dict[column].id for column in self._index_columns
+ ]
+ partition_filter = self._resolve_partition_filter()
+
+ def should_delete(entry: IndexManifestEntry) -> bool:
+ index_file = entry.index_file
+ global_meta = index_file.global_index_meta
+ if index_file.index_type.lower().strip() != self._index_type or
global_meta is None:
+ return False
+ if _indexed_field_ids(global_meta) != target_field_ids:
+ return False
+ return partition_filter is None or
partition_filter.test(entry.partition)
+
+ entries = IndexFileHandler(self._table).scan(snapshot, should_delete)
+ if not entries:
+ return []
+
+ by_partition = {}
+ for entry in entries:
+ key = tuple(entry.partition.values)
+ by_partition.setdefault(key, []).append(
+ IndexManifestEntry(
+ kind=1,
+ partition=entry.partition,
+ bucket=entry.bucket,
+ index_file=entry.index_file,
+ )
+ )
+
+ return [
+ CommitMessage(
+ partition=partition,
+ bucket=0,
+ new_files=[],
+ index_deletes=deletes,
+ )
+ for partition, deletes in by_partition.items()
+ ]
+
+ def _resolve_partition_filter(self) -> Optional[Predicate]:
+ if self._partition_filter is not None:
+ return self._partition_filter
+ if self._partitions is None:
+ return None
+
+ partitions = self._partitions
+ if isinstance(partitions, dict):
+ partitions = [partitions]
+
+ predicate_builder = PredicateBuilder(self._table.partition_keys_fields)
+ partition_predicates = []
+ for partition in partitions:
+ sub_predicates = []
+ for key, value in partition.items():
+ if key not in self._table.partition_keys:
+ raise ValueError(
+ "Partition spec key '%s' is not a partition column. "
+ "Partition keys are: %s"
+ % (key, list(self._table.partition_keys))
+ )
+ if value is None:
+ sub_predicates.append(predicate_builder.is_null(key))
+ else:
+ sub_predicates.append(predicate_builder.equal(key, value))
+ if sub_predicates:
+ partition_predicates.append(
+ predicate_builder.and_predicates(sub_predicates))
+ return predicate_builder.or_predicates(partition_predicates)
+
+
+def _normalize_index_columns(index_column: Union[str, Sequence[str]]) ->
List[str]:
+ if isinstance(index_column, str):
+ return [c.strip() for c in index_column.split(",") if c.strip()]
+ return [str(c).strip() for c in index_column if str(c).strip()]
+
+
+def _indexed_field_ids(global_meta) -> List[int]:
+ field_ids = [global_meta.index_field_id]
+ if global_meta.extra_field_ids:
+ field_ids.extend(global_meta.extra_field_ids)
+ return field_ids
diff --git a/paimon-python/pypaimon/globalindex/key_serializer.py
b/paimon-python/pypaimon/globalindex/key_serializer.py
index 875d6b0bb1..49b0f27356 100644
--- a/paimon-python/pypaimon/globalindex/key_serializer.py
+++ b/paimon-python/pypaimon/globalindex/key_serializer.py
@@ -18,7 +18,11 @@
"""Key serializer for global indexes."""
from abc import ABC, abstractmethod
-from typing import Callable
+import datetime
+from decimal import Decimal, ROUND_HALF_UP
+import math
+import re
+from typing import Callable, Tuple
import struct
from pypaimon.schema.data_types import AtomicType
@@ -88,6 +92,32 @@ class LongSerializer(KeySerializer):
return compare
+class ByteSerializer(KeySerializer):
+ """Serializer for TINYINT type."""
+
+ def serialize(self, key: object) -> bytes:
+ return struct.pack('<b', int(key))
+
+ def deserialize(self, data: bytes) -> object:
+ return struct.unpack('<b', data)[0]
+
+ def create_comparator(self) -> Callable[[object, object], int]:
+ return _numeric_compare
+
+
+class ShortSerializer(KeySerializer):
+ """Serializer for SMALLINT type."""
+
+ def serialize(self, key: object) -> bytes:
+ return struct.pack('<h', int(key))
+
+ def deserialize(self, data: bytes) -> object:
+ return struct.unpack('<h', data)[0]
+
+ def create_comparator(self) -> Callable[[object, object], int]:
+ return _numeric_compare
+
+
class IntSerializer(KeySerializer):
"""Serializer for INT type."""
@@ -109,15 +139,300 @@ class IntSerializer(KeySerializer):
return compare
+class BooleanSerializer(KeySerializer):
+ """Serializer for BOOLEAN type."""
+
+ def serialize(self, key: object) -> bytes:
+ return b'\x01' if bool(key) else b'\x00'
+
+ def deserialize(self, data: bytes) -> object:
+ return data[0] == 1
+
+ def create_comparator(self) -> Callable[[object, object], int]:
+ def compare(a: object, b: object) -> int:
+ return _cmp(bool(a), bool(b))
+ return compare
+
+
+class FloatSerializer(KeySerializer):
+ """Serializer for FLOAT type."""
+
+ def serialize(self, key: object) -> bytes:
+ return struct.pack('<f', float(key))
+
+ def deserialize(self, data: bytes) -> object:
+ return struct.unpack('<f', data)[0]
+
+ def create_comparator(self) -> Callable[[object, object], int]:
+ return _float_compare
+
+
+class DoubleSerializer(KeySerializer):
+ """Serializer for DOUBLE type."""
+
+ def serialize(self, key: object) -> bytes:
+ return struct.pack('<d', float(key))
+
+ def deserialize(self, data: bytes) -> object:
+ return struct.unpack('<d', data)[0]
+
+ def create_comparator(self) -> Callable[[object, object], int]:
+ return _float_compare
+
+
+class DecimalSerializer(KeySerializer):
+ """Serializer for DECIMAL type."""
+
+ def __init__(self, precision: int, scale: int):
+ self._precision = precision
+ self._scale = scale
+
+ def serialize(self, key: object) -> bytes:
+ unscaled = _decimal_unscaled(key, self._scale)
+ if self._precision <= 18:
+ return struct.pack('<q', unscaled)
+ return _signed_big_endian_bytes(unscaled)
+
+ def deserialize(self, data: bytes) -> object:
+ if self._precision <= 18:
+ unscaled = struct.unpack('<q', data)[0]
+ else:
+ unscaled = int.from_bytes(data, byteorder='big', signed=True)
+ return Decimal(unscaled).scaleb(-self._scale)
+
+ def create_comparator(self) -> Callable[[object, object], int]:
+ def compare(a: object, b: object) -> int:
+ return _cmp(Decimal(a), Decimal(b))
+ return compare
+
+
+class DateSerializer(IntSerializer):
+ """Serializer for DATE type."""
+
+ def serialize(self, key: object) -> bytes:
+ return struct.pack('<i', _date_to_epoch_days(key))
+
+ def deserialize(self, data: bytes) -> object:
+ days = struct.unpack('<i', data)[0]
+ return datetime.date(1970, 1, 1) + datetime.timedelta(days=days)
+
+ def create_comparator(self) -> Callable[[object, object], int]:
+ def compare(a: object, b: object) -> int:
+ return _cmp(_date_to_epoch_days(a), _date_to_epoch_days(b))
+ return compare
+
+
+class TimeSerializer(IntSerializer):
+ """Serializer for TIME type."""
+
+ def serialize(self, key: object) -> bytes:
+ return struct.pack('<i', _time_to_millis(key))
+
+ def deserialize(self, data: bytes) -> object:
+ millis = struct.unpack('<i', data)[0]
+ seconds, millis = divmod(millis, 1000)
+ minutes, second = divmod(seconds, 60)
+ hour, minute = divmod(minutes, 60)
+ return datetime.time(hour, minute, second, millis * 1000)
+
+ def create_comparator(self) -> Callable[[object, object], int]:
+ def compare(a: object, b: object) -> int:
+ return _cmp(_time_to_millis(a), _time_to_millis(b))
+ return compare
+
+
+class TimestampSerializer(KeySerializer):
+ """Serializer for TIMESTAMP and TIMESTAMP WITH LOCAL TIME ZONE types."""
+
+ def __init__(self, precision: int):
+ self._precision = precision
+
+ def serialize(self, key: object) -> bytes:
+ millis, nano_of_millisecond = _timestamp_to_millis_nanos(
+ key, self._precision)
+ if self._precision <= 3:
+ return struct.pack('<q', millis)
+ return struct.pack('<q', millis) +
_write_var_len_int(nano_of_millisecond)
+
+ def deserialize(self, data: bytes) -> object:
+ millis = struct.unpack('<q', data[:8])[0]
+ nano_of_millisecond = 0
+ if self._precision > 3 and len(data) > 8:
+ nano_of_millisecond, _ = _read_var_len_int(data, 8)
+ total_micros = millis * 1000 + nano_of_millisecond // 1000
+ return (
+ datetime.datetime(1970, 1, 1)
+ + datetime.timedelta(microseconds=total_micros)
+ )
+
+ def create_comparator(self) -> Callable[[object, object], int]:
+ def compare(a: object, b: object) -> int:
+ return _cmp(
+ _timestamp_to_millis_nanos(a, self._precision),
+ _timestamp_to_millis_nanos(b, self._precision),
+ )
+ return compare
+
+
+def _cmp(a, b) -> int:
+ if a < b:
+ return -1
+ if a > b:
+ return 1
+ return 0
+
+
+def _numeric_compare(a: object, b: object) -> int:
+ return _cmp(int(a), int(b))
+
+
+def _float_compare(a: object, b: object) -> int:
+ left = float(a)
+ right = float(b)
+ left_nan = math.isnan(left)
+ right_nan = math.isnan(right)
+ if left_nan or right_nan:
+ if left_nan and right_nan:
+ return 0
+ return 1 if left_nan else -1
+ return _cmp(left, right)
+
+
+def _parse_decimal_params(type_name: str) -> Tuple[int, int]:
+ if type_name in ("DECIMAL", "NUMERIC"):
+ return 10, 0
+ match = re.fullmatch(r'(?:DECIMAL|NUMERIC)\((\d+),\s*(\d+)\)', type_name)
+ if match:
+ return int(match.group(1)), int(match.group(2))
+ match = re.fullmatch(r'(?:DECIMAL|NUMERIC)\((\d+)\)', type_name)
+ if match:
+ return int(match.group(1)), 0
+ raise ValueError(f"Invalid decimal type: {type_name}")
+
+
+def _parse_precision(type_name: str, default: int) -> int:
+ match = re.search(r'\((\d+)\)', type_name)
+ return int(match.group(1)) if match else default
+
+
+def _decimal_unscaled(value: object, scale: int) -> int:
+ decimal_value = value if isinstance(value, Decimal) else
Decimal(str(value))
+ quant = Decimal(1).scaleb(-scale)
+ rounded = decimal_value.quantize(quant, rounding=ROUND_HALF_UP)
+ return int(rounded.scaleb(scale))
+
+
+def _signed_big_endian_bytes(value: int) -> bytes:
+ length = max(1, (value.bit_length() + 8) // 8)
+ while True:
+ try:
+ encoded = value.to_bytes(length, byteorder='big', signed=True)
+ break
+ except OverflowError:
+ length += 1
+ while len(encoded) > 1:
+ if encoded[0] == 0x00 and encoded[1] < 0x80:
+ encoded = encoded[1:]
+ elif encoded[0] == 0xFF and encoded[1] >= 0x80:
+ encoded = encoded[1:]
+ else:
+ break
+ return encoded
+
+
+def _date_to_epoch_days(value: object) -> int:
+ if isinstance(value, datetime.datetime):
+ value = value.date()
+ if isinstance(value, datetime.date):
+ return (value - datetime.date(1970, 1, 1)).days
+ return int(value)
+
+
+def _time_to_millis(value: object) -> int:
+ if isinstance(value, datetime.time):
+ return (
+ ((value.hour * 60 + value.minute) * 60 + value.second) * 1000
+ + value.microsecond // 1000
+ )
+ return int(value)
+
+
+def _timestamp_to_millis_nanos(
+ value: object, precision: int = 6
+) -> Tuple[int, int]:
+ if hasattr(value, "get_millisecond") and hasattr(value,
"get_nano_of_millisecond"):
+ return int(value.get_millisecond()),
int(value.get_nano_of_millisecond())
+ if isinstance(value, datetime.datetime):
+ if value.tzinfo is None:
+ epoch = datetime.datetime(1970, 1, 1)
+ else:
+ epoch = datetime.datetime(1970, 1, 1, tzinfo=value.tzinfo)
+ delta = value - epoch
+ total_micros = (
+ delta.days * 86_400_000_000
+ + delta.seconds * 1_000_000
+ + delta.microseconds
+ )
+ millis, micros_in_milli = divmod(total_micros, 1000)
+ return millis, micros_in_milli * 1000
+ if precision <= 3:
+ return int(value), 0
+ micros = int(value)
+ millis, micros_in_milli = divmod(micros, 1000)
+ return millis, micros_in_milli * 1000
+
+
+def _write_var_len_int(value: int) -> bytes:
+ if value < 0:
+ raise ValueError("negative value: v=%s" % value)
+ result = bytearray()
+ while value & ~0x7F:
+ result.append((value & 0x7F) | 0x80)
+ value >>= 7
+ result.append(value)
+ return bytes(result)
+
+
+def _read_var_len_int(data: bytes, offset: int = 0):
+ shift = 0
+ result = 0
+ while True:
+ b = data[offset]
+ offset += 1
+ result |= (b & 0x7F) << shift
+ if (b & 0x80) == 0:
+ return result, offset
+ shift += 7
+
+
def create_serializer(data_type: DataType) -> KeySerializer:
if not isinstance(data_type, AtomicType):
raise ValueError(
f"Key serializer only support AtomicType yet, meet
{data_type.__class__}")
type_name = data_type.type.upper()
- if type_name in ('CHAR', 'VARCHAR', 'STRING'):
+ if type_name == 'BOOLEAN':
+ return BooleanSerializer()
+ if type_name == 'TINYINT':
+ return ByteSerializer()
+ if type_name == 'SMALLINT':
+ return ShortSerializer()
+ if type_name in ('CHAR', 'VARCHAR', 'STRING') or
type_name.startswith('CHAR') or type_name.startswith('VARCHAR'):
return StringSerializer()
+ if type_name in ('INT', 'INTEGER'):
+ return IntSerializer()
if type_name == 'BIGINT':
return LongSerializer()
- if type_name == 'INT':
- return IntSerializer()
+ if type_name == 'FLOAT':
+ return FloatSerializer()
+ if type_name == 'DOUBLE':
+ return DoubleSerializer()
+ if type_name.startswith('DECIMAL') or type_name.startswith('NUMERIC'):
+ precision, scale = _parse_decimal_params(type_name)
+ return DecimalSerializer(precision, scale)
+ if type_name == 'DATE':
+ return DateSerializer()
+ if type_name.startswith('TIME') and not type_name.startswith('TIMESTAMP'):
+ return TimeSerializer()
+ if type_name.startswith('TIMESTAMP'):
+ return TimestampSerializer(_parse_precision(type_name, 6))
raise ValueError(f"DataType: {data_type} is not supported by global index
now.")
diff --git a/paimon-python/pypaimon/globalindex/btree/__init__.py
b/paimon-python/pypaimon/globalindex/result_entry.py
similarity index 57%
copy from paimon-python/pypaimon/globalindex/btree/__init__.py
copy to paimon-python/pypaimon/globalindex/result_entry.py
index 17a6a30cbc..ba44a368a3 100644
--- a/paimon-python/pypaimon/globalindex/btree/__init__.py
+++ b/paimon-python/pypaimon/globalindex/result_entry.py
@@ -15,18 +15,14 @@
# specific language governing permissions and limitations
# under the License.
-"""B-tree index implementation for global index."""
+from dataclasses import dataclass
+from typing import Optional
-from pypaimon.globalindex.btree.btree_index_reader import BTreeIndexReader
-from pypaimon.globalindex.btree.btree_index_meta import BTreeIndexMeta
-from pypaimon.globalindex.btree.btree_file_meta_selector import
BTreeFileMetaSelector
-from pypaimon.globalindex.btree.key_serializer import KeySerializer
-from pypaimon.globalindex.btree.lazy_filtered_btree_reader import
LazyFilteredBTreeReader
-__all__ = [
- 'BTreeIndexReader',
- 'BTreeIndexMeta',
- 'BTreeFileMetaSelector',
- 'KeySerializer',
- 'LazyFilteredBTreeReader',
-]
+@dataclass(frozen=True)
+class ResultEntry:
+ """Write result metadata for one global index file."""
+
+ file_name: str
+ row_count: int
+ meta: Optional[bytes]
diff --git a/paimon-python/pypaimon/globalindex/sorted_index_file_meta.py
b/paimon-python/pypaimon/globalindex/sorted_index_file_meta.py
index be3e10b73a..e9d5d9b5ab 100644
--- a/paimon-python/pypaimon/globalindex/sorted_index_file_meta.py
+++ b/paimon-python/pypaimon/globalindex/sorted_index_file_meta.py
@@ -37,6 +37,29 @@ class SortedIndexFileMeta:
def only_nulls(self) -> bool:
return self.first_key is None and self.last_key is None
+ def serialize(self) -> bytes:
+ result = bytearray()
+ null_key_flags = 0
+
+ if self.first_key is None:
+ result.extend(struct.pack('<I', 0))
+ null_key_flags |= self.FIRST_KEY_IS_NULL
+ else:
+ result.extend(struct.pack('<I', len(self.first_key)))
+ result.extend(self.first_key)
+
+ if self.last_key is None:
+ result.extend(struct.pack('<I', 0))
+ null_key_flags |= self.LAST_KEY_IS_NULL
+ else:
+ result.extend(struct.pack('<I', len(self.last_key)))
+ result.extend(self.last_key)
+
+ result.extend(struct.pack('<B', 1 if self.has_nulls else 0))
+ result.extend(struct.pack('<B', self.FORMAT_VERSION_WITH_NULL_FLAGS))
+ result.extend(struct.pack('<B', null_key_flags))
+ return bytes(result)
+
@classmethod
def deserialize(cls, data: bytes) -> 'SortedIndexFileMeta':
offset = 0
diff --git a/paimon-python/pypaimon/manifest/index_manifest_file.py
b/paimon-python/pypaimon/manifest/index_manifest_file.py
index 62ca9ea16d..2d2f73e4da 100644
--- a/paimon-python/pypaimon/manifest/index_manifest_file.py
+++ b/paimon-python/pypaimon/manifest/index_manifest_file.py
@@ -36,7 +36,7 @@ _DELETION_VECTOR_META_SCHEMA = {
"name": "DeletionVectorMeta",
"fields": [
{"name": "f0", "type": "string"},
- {"name": "f1", "type": "long"},
+ {"name": "f1", "type": "int"},
{"name": "f2", "type": "int"},
{"name": "_CARDINALITY", "type": ["null", "long"], "default": None},
],
@@ -77,6 +77,8 @@ INDEX_MANIFEST_ENTRY_SCHEMA = {
}
_INDEX_ENTRY_VERSION = 1
+_ADD = 0
+_HASH_INDEX = "HASH"
class IndexManifestFile:
@@ -234,14 +236,24 @@ class IndexManifestFile:
previous_name: Optional[str],
deletes: List[IndexManifestEntry],
) -> Optional[str]:
- if not deletes:
+ return self.combine_changes(previous_name, [], deletes)
+
+ def combine_changes(
+ self,
+ previous_name: Optional[str],
+ adds: List[IndexManifestEntry],
+ deletes: List[IndexManifestEntry],
+ ) -> Optional[str]:
+ if not adds and not deletes:
return previous_name
previous = self.read(previous_name) if previous_name else []
delete_names = {e.index_file.file_name for e in deletes}
survivors = [e for e in previous if e.index_file.file_name not in
delete_names]
- if not survivors:
+ _validate_retained_global_index_files(survivors, adds)
+ combined = survivors + adds
+ if not combined:
return None
- return self.write(survivors)
+ return self.write(combined)
def write(self, entries: List[IndexManifestEntry]) -> str:
file_name =
f"{FileStorePathFactory.INDEX_MANIFEST_PREFIX}{uuid.uuid4()}"
@@ -293,3 +305,64 @@ class IndexManifestFile:
"_EXTERNAL_PATH": index_file.external_path,
"_GLOBAL_INDEX": global_index,
}
+
+
+def _validate_retained_global_index_files(
+ retained_entries: List[IndexManifestEntry],
+ added_entries: List[IndexManifestEntry],
+) -> None:
+ for retained in retained_entries:
+ retained_file = retained.index_file
+ retained_meta = retained_file.global_index_meta
+ if retained_meta is None or not
_is_global_index(retained_file.index_type):
+ continue
+
+ for added in added_entries:
+ added_file = added.index_file
+ added_meta = added_file.global_index_meta
+ if (
+ added.kind != _ADD
+ or added_meta is None
+ or added_file.index_type != retained_file.index_type
+ or retained_meta.index_field_id != added_meta.index_field_id
+ or _can_keep_existing_global_index(retained_meta, added_meta)
+ ):
+ continue
+
+ raise RuntimeError(
+ "Trying to add global index file %s of type %s for index field
%s"
+ " with row range [%s, %s], but previous file %s still exists"
+ " with overlapping row range [%s, %s]. Remove the previous
file first."
+ % (
+ added_file.file_name,
+ added_file.index_type,
+ added_meta.index_field_id,
+ added_meta.row_range_start,
+ added_meta.row_range_end,
+ retained_file.file_name,
+ retained_meta.row_range_start,
+ retained_meta.row_range_end,
+ )
+ )
+
+
+def _is_global_index(index_type: str) -> bool:
+ return index_type not in (IndexManifestFile.DELETION_VECTORS_INDEX,
_HASH_INDEX)
+
+
+def _can_keep_existing_global_index(retained_meta, added_meta) -> bool:
+ return (
+ _extra_field_ids(retained_meta) == _extra_field_ids(added_meta)
+ and not _row_ranges_intersect(retained_meta, added_meta)
+ )
+
+
+def _extra_field_ids(global_index_meta) -> Optional[List[int]]:
+ return global_index_meta.extra_field_ids or None
+
+
+def _row_ranges_intersect(left, right) -> bool:
+ return (
+ left.row_range_start <= right.row_range_end
+ and right.row_range_start <= left.row_range_end
+ )
diff --git a/paimon-python/pypaimon/table/file_store_table.py
b/paimon-python/pypaimon/table/file_store_table.py
index dd561eb55b..56e2b9518c 100644
--- a/paimon-python/pypaimon/table/file_store_table.py
+++ b/paimon-python/pypaimon/table/file_store_table.py
@@ -394,6 +394,7 @@ class FileStoreTable(Table):
external_path_strategy=self.options.data_file_external_paths_strategy(),
external_path_weights=self.options.data_file_external_paths_weights(),
index_file_in_data_file_dir=False,
+
global_index_external_path=self.options.global_index_external_path(),
)
def new_snapshot_commit(self):
@@ -449,6 +450,32 @@ class FileStoreTable(Table):
BatchVectorSearchBuilderImpl
return BatchVectorSearchBuilderImpl(self)
+ def create_global_index(self, index_column, index_type: str = "btree",
+ partition_filter=None, partitions=None,
+ options: Optional[dict] = None) -> int:
+ from pypaimon.globalindex.create_global_index import
create_global_index
+ return create_global_index(
+ self,
+ index_column,
+ index_type=index_type,
+ partition_filter=partition_filter,
+ partitions=partitions,
+ options=options,
+ )
+
+ def drop_global_index(self, index_column, index_type: str = "btree",
+ partition_filter=None, partitions=None,
+ dry_run: bool = False) -> int:
+ from pypaimon.globalindex.drop_global_index import drop_global_index
+ return drop_global_index(
+ self,
+ index_column,
+ index_type=index_type,
+ partition_filter=partition_filter,
+ partitions=partitions,
+ dry_run=dry_run,
+ )
+
def create_row_key_extractor(self) -> RowKeyExtractor:
bucket_mode = self.bucket_mode()
if bucket_mode == BucketMode.HASH_FIXED:
diff --git a/paimon-python/pypaimon/table/system/file_key_ranges_table.py
b/paimon-python/pypaimon/table/system/file_key_ranges_table.py
new file mode 100644
index 0000000000..67b60ff5bd
--- /dev/null
+++ b/paimon-python/pypaimon/table/system/file_key_ranges_table.py
@@ -0,0 +1,145 @@
+# 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.
+
+"""The ``$file_key_ranges`` system table."""
+
+from typing import List
+
+import pyarrow
+
+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
+from pypaimon.table.system.files_table import (
+ _render_key,
+ _render_partition,
+ _stringify_path,
+)
+from pypaimon.table.system.system_table import SystemTable
+
+
+TABLE_TYPE = RowType(False, [
+ DataField(0, "partition", AtomicType("STRING", nullable=True)),
+ DataField(1, "bucket", AtomicType("INT", nullable=False)),
+ DataField(2, "file_path", AtomicType("STRING", nullable=False)),
+ DataField(3, "file_format", AtomicType("STRING", nullable=False)),
+ DataField(4, "schema_id", AtomicType("BIGINT", nullable=False)),
+ DataField(5, "level", AtomicType("INT", nullable=False)),
+ DataField(6, "record_count", AtomicType("BIGINT", nullable=False)),
+ DataField(7, "file_size_in_bytes", AtomicType("BIGINT", nullable=False)),
+ DataField(8, "min_key", AtomicType("STRING", nullable=True)),
+ DataField(9, "max_key", AtomicType("STRING", nullable=True)),
+ DataField(10, "first_row_id", AtomicType("BIGINT", nullable=True)),
+])
+
+
+class FileKeyRangesTable(SystemTable):
+ """The ``$file_key_ranges`` system table."""
+
+ def system_table_name(self) -> str:
+ return "file_key_ranges"
+
+ def row_type(self) -> RowType:
+ return TABLE_TYPE
+
+ def primary_keys(self) -> List[str]:
+ return ["file_path"]
+
+ def _build_arrow_table(self) -> pyarrow.Table:
+ snapshot = self.base_table.snapshot_manager().get_latest_snapshot()
+ if snapshot is None:
+ return _empty_table()
+
+ manifest_list_manager = ManifestListManager(self.base_table)
+ manifest_files = manifest_list_manager.read_all(snapshot)
+ manifest_file_manager = ManifestFileManager(self.base_table)
+ entries = manifest_file_manager.read_entries_parallel(
+ manifest_files, drop_stats=False)
+
+ file_format = self.base_table.options.file_format()
+ path_factory = self.base_table.path_factory()
+ rows = {
+ "partition": [],
+ "bucket": [],
+ "file_path": [],
+ "file_format": [],
+ "schema_id": [],
+ "level": [],
+ "record_count": [],
+ "file_size_in_bytes": [],
+ "min_key": [],
+ "max_key": [],
+ "first_row_id": [],
+ }
+
+ for entry in entries:
+ meta = entry.file
+ bucket_path = path_factory.bucket_path(
+ tuple(entry.partition.values), int(entry.bucket))
+ rows["partition"].append(_render_partition(entry.partition))
+ rows["bucket"].append(int(entry.bucket))
+ rows["file_path"].append(_stringify_path(
+ meta.external_path
+ or meta.file_path
+ or "%s/%s" % (bucket_path, meta.file_name)))
+ rows["file_format"].append(file_format)
+ rows["schema_id"].append(int(meta.schema_id))
+ rows["level"].append(int(meta.level))
+ rows["record_count"].append(int(meta.row_count))
+ rows["file_size_in_bytes"].append(int(meta.file_size))
+ rows["min_key"].append(_render_key(meta.min_key))
+ rows["max_key"].append(_render_key(meta.max_key))
+ rows["first_row_id"].append(
+ None if meta.first_row_id is None
+ else int(meta.first_row_id))
+
+ return pyarrow.table({
+ "partition": pyarrow.array(
+ rows["partition"], type=pyarrow.string()),
+ "bucket": pyarrow.array(rows["bucket"], type=pyarrow.int32()),
+ "file_path": pyarrow.array(
+ rows["file_path"], type=pyarrow.string()),
+ "file_format": pyarrow.array(
+ rows["file_format"], type=pyarrow.string()),
+ "schema_id": pyarrow.array(
+ rows["schema_id"], type=pyarrow.int64()),
+ "level": pyarrow.array(rows["level"], type=pyarrow.int32()),
+ "record_count": pyarrow.array(
+ rows["record_count"], type=pyarrow.int64()),
+ "file_size_in_bytes": pyarrow.array(
+ rows["file_size_in_bytes"], type=pyarrow.int64()),
+ "min_key": pyarrow.array(rows["min_key"], type=pyarrow.string()),
+ "max_key": pyarrow.array(rows["max_key"], type=pyarrow.string()),
+ "first_row_id": pyarrow.array(
+ rows["first_row_id"], type=pyarrow.int64()),
+ })
+
+
+def _empty_table() -> pyarrow.Table:
+ return pyarrow.table({
+ "partition": pyarrow.array([], type=pyarrow.string()),
+ "bucket": pyarrow.array([], type=pyarrow.int32()),
+ "file_path": pyarrow.array([], type=pyarrow.string()),
+ "file_format": pyarrow.array([], type=pyarrow.string()),
+ "schema_id": pyarrow.array([], type=pyarrow.int64()),
+ "level": pyarrow.array([], type=pyarrow.int32()),
+ "record_count": pyarrow.array([], type=pyarrow.int64()),
+ "file_size_in_bytes": pyarrow.array([], type=pyarrow.int64()),
+ "min_key": pyarrow.array([], type=pyarrow.string()),
+ "max_key": pyarrow.array([], type=pyarrow.string()),
+ "first_row_id": pyarrow.array([], type=pyarrow.int64()),
+ })
diff --git a/paimon-python/pypaimon/table/system/system_table_loader.py
b/paimon-python/pypaimon/table/system/system_table_loader.py
index 72b758947d..c2f50649a5 100644
--- a/paimon-python/pypaimon/table/system/system_table_loader.py
+++ b/paimon-python/pypaimon/table/system/system_table_loader.py
@@ -24,8 +24,7 @@ new module.
The following short names are intentionally not registered here yet:
audit_log, binlog, read_optimized, consumers, statistics,
- aggregation_fields, file_key_ranges, table_indexes,
- row_tracking, all_tables, all_partitions, all_table_options,
+ aggregation_fields, row_tracking, all_tables, all_partitions,
all_table_options,
catalog_options
"""
@@ -47,6 +46,8 @@ SYSTEM_TABLES: Tuple[str, ...] = (
"buckets",
"tags",
"branches",
+ "file_key_ranges",
+ "table_indexes",
)
@@ -70,6 +71,10 @@ SYSTEM_TABLE_LOADERS: Dict[str, Callable[...,
"SystemTable"]] = {
"buckets": _lazy("pypaimon.table.system.buckets_table", "BucketsTable"),
"tags": _lazy("pypaimon.table.system.tags_table", "TagsTable"),
"branches": _lazy("pypaimon.table.system.branches_table", "BranchesTable"),
+ "file_key_ranges": _lazy(
+ "pypaimon.table.system.file_key_ranges_table", "FileKeyRangesTable"),
+ "table_indexes": _lazy(
+ "pypaimon.table.system.table_indexes_table", "TableIndexesTable"),
}
diff --git a/paimon-python/pypaimon/table/system/table_indexes_table.py
b/paimon-python/pypaimon/table/system/table_indexes_table.py
new file mode 100644
index 0000000000..4bf2103f0b
--- /dev/null
+++ b/paimon-python/pypaimon/table/system/table_indexes_table.py
@@ -0,0 +1,169 @@
+# 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.
+
+"""The ``$table_indexes`` system table."""
+
+from typing import Dict, List, Optional
+
+import pyarrow
+
+from pypaimon.index.index_file_handler import IndexFileHandler
+from pypaimon.schema.data_types import (ArrayType, AtomicType, DataField,
+ RowType)
+from pypaimon.table.system.files_table import _render_partition
+from pypaimon.table.system.system_table import SystemTable
+
+
+_DV_RANGE_ROW_TYPE = RowType(False, [
+ DataField(0, "f0", AtomicType("STRING", nullable=False)),
+ DataField(1, "f1", AtomicType("INT", nullable=False)),
+ DataField(2, "f2", AtomicType("INT", nullable=False)),
+ DataField(3, "_CARDINALITY", AtomicType("BIGINT", nullable=True)),
+])
+_DV_RANGES_TYPE = pyarrow.list_(pyarrow.struct([
+ pyarrow.field("f0", pyarrow.string(), nullable=False),
+ pyarrow.field("f1", pyarrow.int32(), nullable=False),
+ pyarrow.field("f2", pyarrow.int32(), nullable=False),
+ pyarrow.field("_CARDINALITY", pyarrow.int64(), nullable=True),
+]))
+
+TABLE_TYPE = RowType(False, [
+ DataField(0, "partition", AtomicType("STRING", nullable=True)),
+ DataField(1, "bucket", AtomicType("INT", nullable=False)),
+ DataField(2, "index_type", AtomicType("STRING", nullable=False)),
+ DataField(3, "file_name", AtomicType("STRING", nullable=False)),
+ DataField(4, "file_size", AtomicType("BIGINT", nullable=False)),
+ DataField(5, "row_count", AtomicType("BIGINT", nullable=False)),
+ DataField(
+ 6,
+ "dv_ranges",
+ ArrayType(nullable=True,
+ element_type=_DV_RANGE_ROW_TYPE)),
+ DataField(7, "row_range_start", AtomicType("BIGINT", nullable=True)),
+ DataField(8, "row_range_end", AtomicType("BIGINT", nullable=True)),
+ DataField(9, "index_field_id", AtomicType("INT", nullable=True)),
+ DataField(10, "index_field_name", AtomicType("STRING", nullable=True)),
+])
+
+
+class TableIndexesTable(SystemTable):
+ """The ``$table_indexes`` system table."""
+
+ def system_table_name(self) -> str:
+ return "table_indexes"
+
+ def row_type(self) -> RowType:
+ return TABLE_TYPE
+
+ def primary_keys(self) -> List[str]:
+ return ["file_name"]
+
+ def _build_arrow_table(self) -> pyarrow.Table:
+ snapshot = self.base_table.snapshot_manager().get_latest_snapshot()
+ entries = IndexFileHandler(self.base_table).scan(snapshot)
+
+ rows = {
+ "partition": [],
+ "bucket": [],
+ "index_type": [],
+ "file_name": [],
+ "file_size": [],
+ "row_count": [],
+ "dv_ranges": [],
+ "row_range_start": [],
+ "row_range_end": [],
+ "index_field_id": [],
+ "index_field_name": [],
+ }
+
+ for entry in entries:
+ index_file = entry.index_file
+ global_meta = index_file.global_index_meta
+ rows["partition"].append(_render_partition(entry.partition))
+ rows["bucket"].append(int(entry.bucket))
+ rows["index_type"].append(index_file.index_type)
+ rows["file_name"].append(index_file.file_name)
+ rows["file_size"].append(int(index_file.file_size))
+ rows["row_count"].append(int(index_file.row_count))
+ rows["dv_ranges"].append(_render_dv_ranges(index_file.dv_ranges))
+ rows["row_range_start"].append(
+ None if global_meta is None
+ else int(global_meta.row_range_start))
+ rows["row_range_end"].append(
+ None if global_meta is None
+ else int(global_meta.row_range_end))
+ rows["index_field_id"].append(
+ None if global_meta is None
+ else int(global_meta.index_field_id))
+ rows["index_field_name"].append(
+ None if global_meta is None
+ else _index_field_names(self.base_table, global_meta))
+
+ return pyarrow.table({
+ "partition": pyarrow.array(
+ rows["partition"], type=pyarrow.string()),
+ "bucket": pyarrow.array(rows["bucket"], type=pyarrow.int32()),
+ "index_type": pyarrow.array(
+ rows["index_type"], type=pyarrow.string()),
+ "file_name": pyarrow.array(
+ rows["file_name"], type=pyarrow.string()),
+ "file_size": pyarrow.array(
+ rows["file_size"], type=pyarrow.int64()),
+ "row_count": pyarrow.array(
+ rows["row_count"], type=pyarrow.int64()),
+ "dv_ranges": pyarrow.array(
+ rows["dv_ranges"], type=_DV_RANGES_TYPE),
+ "row_range_start": pyarrow.array(
+ rows["row_range_start"], type=pyarrow.int64()),
+ "row_range_end": pyarrow.array(
+ rows["row_range_end"], type=pyarrow.int64()),
+ "index_field_id": pyarrow.array(
+ rows["index_field_id"], type=pyarrow.int32()),
+ "index_field_name": pyarrow.array(
+ rows["index_field_name"], type=pyarrow.string()),
+ })
+
+
+def _render_dv_ranges(dv_ranges) -> Optional[List[Dict[str, object]]]:
+ if not dv_ranges:
+ return None
+ rendered = []
+ for meta in dv_ranges.values():
+ rendered.append({
+ "f0": meta.data_file_name,
+ "f1": int(meta.offset),
+ "f2": int(meta.length),
+ "_CARDINALITY": (
+ None if meta.cardinality is None else int(meta.cardinality)
+ ),
+ })
+ return rendered
+
+
+def _index_field_names(table, global_meta) -> Optional[str]:
+ field_by_id = {field.id: field.name for field in table.fields}
+ field_ids = [global_meta.index_field_id]
+ if global_meta.extra_field_ids:
+ field_ids.extend(global_meta.extra_field_ids)
+
+ names = []
+ for field_id in field_ids:
+ name = field_by_id.get(field_id)
+ if name is None:
+ return None
+ names.append(name)
+ return ",".join(names)
diff --git a/paimon-python/pypaimon/tests/global_index_build_test.py
b/paimon-python/pypaimon/tests/global_index_build_test.py
new file mode 100644
index 0000000000..15fd3bfd39
--- /dev/null
+++ b/paimon-python/pypaimon/tests/global_index_build_test.py
@@ -0,0 +1,292 @@
+# 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
+from datetime import date, datetime
+from decimal import Decimal
+import os
+
+import pyarrow as pa
+
+from pypaimon.globalindex.create_global_index import (
+ _split_one_by_contiguous_row_range,
+)
+from pypaimon.globalindex.key_serializer import create_serializer
+from pypaimon.globalindex.global_index_scanner import GlobalIndexScanner
+from pypaimon.index.index_file_handler import IndexFileHandler
+from pypaimon.schema.data_types import ArrayType, AtomicType, RowType
+from pypaimon.tests.data_evolution_test_helpers import (
+ BatchModeMixin,
+ DataEvolutionTestBase,
+)
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.utils.range import Range
+
+
+class _FakeFile:
+
+ def __init__(self, file_name, first_row_id, row_count):
+ self.file_name = file_name
+ self.first_row_id = first_row_id
+ self.row_count = row_count
+
+ def row_id_range(self):
+ return Range(self.first_row_id,
+ self.first_row_id + self.row_count - 1)
+
+
+class _FakeSplit:
+
+ def __init__(self, files):
+ self.files = files
+ self.partition = GenericRow([], [])
+ self.bucket = 0
+ self.raw_convertible = False
+
+
+class GlobalIndexBuildTest(
+ BatchModeMixin, DataEvolutionTestBase, unittest.TestCase):
+
+ table_options = {
+ 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true',
+ 'global-index.enabled': 'true',
+ 'bucket': '-1',
+ 'file.format': 'parquet',
+ }
+
+ def test_create_btree_global_index_from_python(self):
+ table = self._create_table()
+ self._write_arrow(table, pa.table(
+ {
+ 'id': [3, 1, 2, 2],
+ 'name': ['c', 'a', 'b1', 'b2'],
+ 'age': [30, 10, 20, 21],
+ 'city': ['z', 'x', 'y', 'y2'],
+ },
+ schema=self.pa_schema,
+ ))
+
+ added = table.create_global_index(
+ 'id',
+ options={'sorted-index.records-per-range': '2'},
+ )
+
+ self.assertEqual(2, added)
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ self.assertIsNotNone(snapshot.index_manifest)
+
+ entries = IndexFileHandler(table).scan(snapshot)
+ self.assertEqual(2, len(entries))
+ self.assertEqual({'btree'}, {e.index_file.index_type for e in entries})
+ self.assertEqual({0}, {e.index_file.global_index_meta.row_range_start
for e in entries})
+ self.assertEqual({3}, {e.index_file.global_index_meta.row_range_end
for e in entries})
+
+ read_builder = table.new_read_builder()
+ predicate = read_builder.new_predicate_builder().equal('id', 2)
+ with GlobalIndexScanner.create(
+ table,
+ predicate=predicate,
+ snapshot=snapshot) as scanner:
+ result = scanner.scan(predicate)
+
+ self.assertEqual(
+ [Range(2, 3)],
+ result.results().to_range_list(),
+ )
+
+ table_name = table.identifier.get_full_name()
+ table_indexes = self.catalog.get_table(table_name + '$table_indexes')
+ index_read_builder = table_indexes.new_read_builder().with_projection([
+ 'index_type',
+ 'index_field_name',
+ 'row_range_start',
+ 'row_range_end',
+ ])
+ index_table = index_read_builder.new_read().to_arrow(
+ index_read_builder.new_scan().plan().splits())
+ self.assertEqual(2, index_table.num_rows)
+ self.assertEqual(['btree', 'btree'],
+ index_table.column('index_type').to_pylist())
+ self.assertEqual(['id', 'id'],
+ index_table.column('index_field_name').to_pylist())
+ self.assertEqual([0, 0],
+ index_table.column('row_range_start').to_pylist())
+ self.assertEqual([3, 3],
+ index_table.column('row_range_end').to_pylist())
+
+ key_ranges = self.catalog.get_table(table_name + '$file_key_ranges')
+ range_read_builder = key_ranges.new_read_builder().with_projection([
+ 'file_path',
+ 'record_count',
+ 'first_row_id',
+ ])
+ range_table = range_read_builder.new_read().to_arrow(
+ range_read_builder.new_scan().plan().splits())
+ self.assertEqual(1, range_table.num_rows)
+ file_path = range_table.column('file_path').to_pylist()[0]
+ self.assertIn('/bucket-0/', file_path)
+ self.assertEqual([4], range_table.column('record_count').to_pylist())
+ self.assertEqual([0], range_table.column('first_row_id').to_pylist())
+
+ dv_ranges_type = table_indexes.row_type().fields[6].type
+ self.assertIsInstance(dv_ranges_type, ArrayType)
+ self.assertIsInstance(dv_ranges_type.element, RowType)
+ self.assertEqual(
+ ['f0', 'f1', 'f2', '_CARDINALITY'],
+ [field.name for field in dv_ranges_type.element.fields],
+ )
+
+ self.assertEqual(2, table.drop_global_index('id', dry_run=True))
+ self.assertEqual(2, len(IndexFileHandler(table).scan(
+ table.snapshot_manager().get_latest_snapshot())))
+
+ self.assertEqual(2, table.drop_global_index('id'))
+ latest_snapshot = table.snapshot_manager().get_latest_snapshot()
+ self.assertEqual([], IndexFileHandler(table).scan(latest_snapshot))
+
+ index_read_builder = table_indexes.new_read_builder()
+ index_table = index_read_builder.new_read().to_arrow(
+ index_read_builder.new_scan().plan().splits())
+ self.assertEqual(0, index_table.num_rows)
+
+ def test_create_global_index_uses_external_path(self):
+ external_root = 'file://%s' % os.path.join(
+ self.tempdir, 'global-index-external')
+ options = dict(self.table_options)
+ options['global-index.external-path'] = external_root
+ table = self._create_table(options=options)
+ self._write_arrow(table, pa.table(
+ {
+ 'id': [1, 2],
+ 'name': ['a', 'b'],
+ 'age': [10, 20],
+ 'city': ['x', 'y'],
+ },
+ schema=self.pa_schema,
+ ))
+
+ self.assertEqual(1, table.create_global_index('id'))
+
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ entries = IndexFileHandler(table).scan(snapshot)
+ self.assertEqual(1, len(entries))
+ external_path = entries[0].index_file.external_path
+ self.assertIsNotNone(external_path)
+ self.assertTrue(external_path.startswith(external_root + '/'))
+ self.assertTrue(table.file_io.exists(external_path))
+
+ def test_create_global_index_rejects_overlapping_existing_range(self):
+ table = self._create_table()
+ self._write_arrow(table, pa.table(
+ {
+ 'id': [3, 1, 2, 2],
+ 'name': ['c', 'a', 'b1', 'b2'],
+ 'age': [30, 10, 20, 21],
+ 'city': ['z', 'x', 'y', 'y2'],
+ },
+ schema=self.pa_schema,
+ ))
+ options = {'sorted-index.records-per-range': '2'}
+
+ self.assertEqual(2, table.create_global_index('id', options=options))
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ self.assertEqual(2, len(IndexFileHandler(table).scan(snapshot)))
+
+ with self.assertRaisesRegex(RuntimeError, 'overlapping row range'):
+ table.create_global_index('id', options=options)
+
+ latest_snapshot = table.snapshot_manager().get_latest_snapshot()
+ self.assertEqual(2, len(IndexFileHandler(table).scan(latest_snapshot)))
+
+ def test_create_btree_global_index_for_java_scalar_types(self):
+ schema = pa.schema([
+ ('flag', pa.bool_()),
+ ('amount', pa.decimal128(10, 2)),
+ ('dt', pa.date32()),
+ ('ts', pa.timestamp('us')),
+ ('payload', pa.string()),
+ ])
+ table = self._create_table(pa_schema=schema,
options=self.table_options)
+ self._write_arrow(table, pa.table(
+ {
+ 'flag': [True, False, True],
+ 'amount': [
+ Decimal('10.25'), Decimal('20.50'), Decimal('30.75')],
+ 'dt': [
+ date(2026, 6, 18),
+ date(2026, 6, 19),
+ date(2026, 6, 20),
+ ],
+ 'ts': [
+ datetime(2026, 6, 18, 10, 0, 0, 123456),
+ datetime(2026, 6, 19, 10, 0, 0, 123456),
+ datetime(2026, 6, 20, 10, 0, 0, 123456),
+ ],
+ 'payload': ['a', 'b', 'c'],
+ },
+ schema=schema,
+ ))
+
+ for column in ['flag', 'amount', 'dt', 'ts']:
+ self.assertEqual(1, table.create_global_index(column))
+
+ def test_split_by_contiguous_row_range_matches_java_builder(self):
+ split = _FakeSplit([
+ _FakeFile('a', 0, 2),
+ _FakeFile('b', 4, 2),
+ _FakeFile('c', 6, 1),
+ _FakeFile('d', 10, 1),
+ ])
+
+ splits = _split_one_by_contiguous_row_range(split)
+
+ self.assertEqual(
+ [['a'], ['b', 'c'], ['d']],
+ [[file.file_name for file in s.files] for s in splits],
+ )
+
+ def test_java_scalar_key_serializers_round_trip(self):
+ cases = [
+ ('BOOLEAN', True),
+ ('TINYINT', -7),
+ ('SMALLINT', 1024),
+ ('INT', 42),
+ ('BIGINT', 1234567890123),
+ ('FLOAT', 1.25),
+ ('DOUBLE', 3.14159),
+ ('DECIMAL(20, 5)', Decimal('-1234567890123.45678')),
+ ('DATE', date(2026, 6, 18)),
+ ('TIME(3)', datetime(2026, 6, 18, 1, 2, 3, 456000).time()),
+ ('TIMESTAMP(6)', datetime(2026, 6, 18, 1, 2, 3, 456789)),
+ ('VARCHAR(16)', 'abc'),
+ ]
+
+ for type_name, value in cases:
+ with self.subTest(type_name=type_name):
+ serializer = create_serializer(AtomicType(type_name))
+ actual = serializer.deserialize(serializer.serialize(value))
+ if type_name == 'FLOAT':
+ self.assertAlmostEqual(value, actual, places=6)
+ elif type_name == 'DOUBLE':
+ self.assertAlmostEqual(value, actual, places=12)
+ else:
+ self.assertEqual(value, actual)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/paimon-python/pypaimon/tests/system/system_table_loader_test.py
b/paimon-python/pypaimon/tests/system/system_table_loader_test.py
index 07b959a2c6..3553d647a2 100644
--- a/paimon-python/pypaimon/tests/system/system_table_loader_test.py
+++ b/paimon-python/pypaimon/tests/system/system_table_loader_test.py
@@ -31,6 +31,8 @@ _EXPECTED_SYSTEM_TABLES = (
"buckets",
"tags",
"branches",
+ "file_key_ranges",
+ "table_indexes",
)
# Short names recognised by the Paimon catalog that this loader does
@@ -42,8 +44,6 @@ _UNREGISTERED_NAMES = {
"consumers",
"statistics",
"aggregation_fields",
- "file_key_ranges",
- "table_indexes",
"row_tracking",
"all_tables",
"all_partitions",
diff --git a/paimon-python/pypaimon/utils/file_store_path_factory.py
b/paimon-python/pypaimon/utils/file_store_path_factory.py
index 3816a2ce29..a98ddf00ce 100644
--- a/paimon-python/pypaimon/utils/file_store_path_factory.py
+++ b/paimon-python/pypaimon/utils/file_store_path_factory.py
@@ -58,6 +58,7 @@ class FileStorePathFactory:
external_path_strategy: str = "round-robin",
external_path_weights: Optional[List[int]] = None,
index_file_in_data_file_dir: bool = False,
+ global_index_external_path: Optional[str] = None,
):
self._root = root.rstrip('/')
self.partition_keys = partition_keys
@@ -73,6 +74,9 @@ class FileStorePathFactory:
self.external_path_weights = external_path_weights
self.index_file_in_data_file_dir = index_file_in_data_file_dir
self.legacy_partition_name = legacy_partition_name
+ self.global_index_external_path = (
+ global_index_external_path.rstrip('/')
+ if global_index_external_path else None)
def root(self) -> str:
return self._root
@@ -83,6 +87,9 @@ class FileStorePathFactory:
def index_path(self) -> str:
return f"{self._root}/{self.INDEX_PATH}"
+ def global_index_root_path(self) -> str:
+ return self.global_index_external_path or self.index_path()
+
def statistics_path(self) -> str:
return f"{self._root}/{self.STATISTICS_PATH}"
@@ -136,22 +143,37 @@ class FileStorePathFactory:
)
def global_index_path_factory(self) -> 'IndexPathFactory':
- return IndexPathFactory(self.index_path())
+ return IndexPathFactory(
+ self.index_path(),
+ self.global_index_root_path(),
+ self.global_index_external_path is not None,
+ )
class IndexPathFactory:
- def __init__(self, index_path: str):
+ def __init__(
+ self,
+ index_path: str,
+ global_index_root_path: Optional[str] = None,
+ external_path: bool = False,
+ ):
self._index_path = index_path
+ self._global_index_root_path = global_index_root_path or index_path
+ self._external_path = external_path
self._file_count = 0
def index_path(self) -> str:
- """Return the base index path."""
+ """Return the table index path used as a read fallback."""
return self._index_path
+ def global_index_root_path(self) -> str:
+ """Return the root path for newly written global index files."""
+ return self._global_index_root_path
+
def to_path(self, file_name: str) -> str:
"""Convert a file name to a full path."""
- return f"{self._index_path}/{file_name}"
+ return f"{self._global_index_root_path}/{file_name}"
def new_path(self, prefix: str = "index-") -> str:
"""Create a new unique index file path."""
@@ -162,4 +184,4 @@ class IndexPathFactory:
def is_external_path(self) -> bool:
"""Return whether this is an external path."""
- return False
+ return self._external_path
diff --git a/paimon-python/pypaimon/write/commit_message.py
b/paimon-python/pypaimon/write/commit_message.py
index a8cc193727..99e5e68f8c 100644
--- a/paimon-python/pypaimon/write/commit_message.py
+++ b/paimon-python/pypaimon/write/commit_message.py
@@ -30,8 +30,14 @@ class CommitMessage:
bucket: int
new_files: List[DataFileMeta]
check_from_snapshot: Optional[int] = -1
+ index_adds: List['IndexManifestEntry'] = field(default_factory=list)
index_deletes: List['IndexManifestEntry'] = field(default_factory=list)
changelog_files: List[DataFileMeta] = field(default_factory=list)
def is_empty(self):
- return not self.new_files and not self.index_deletes and not
self.changelog_files
+ return (
+ not self.new_files
+ and not self.index_adds
+ and not self.index_deletes
+ and not self.changelog_files
+ )
diff --git a/paimon-python/pypaimon/write/file_store_commit.py
b/paimon-python/pypaimon/write/file_store_commit.py
index fb90f1aa90..0304178195 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -156,8 +156,10 @@ class FileStoreCommit:
len(commit_entries), len(changelog_entries))
index_deletes = []
+ index_adds = []
for msg in commit_messages:
index_deletes.extend(msg.index_deletes)
+ index_adds.extend(msg.index_adds)
if not index_deletes:
from pypaimon.write.global_index_update_checker import (
@@ -197,7 +199,8 @@ class FileStoreCommit:
changelog_entries=changelog_entries,
detect_conflicts=detect_conflicts,
allow_rollback=allow_rollback,
- index_deletes=index_deletes)
+ index_deletes=index_deletes,
+ index_adds=index_adds)
def overwrite(self, overwrite_partition, commit_messages:
List[CommitMessage], commit_identifier: int):
"""Commit the given commit messages in overwrite mode."""
@@ -286,7 +289,8 @@ class FileStoreCommit:
)
def _try_commit(self, commit_kind, commit_identifier, commit_entries_plan,
- detect_conflicts=False, allow_rollback=False,
index_deletes=None, changelog_entries=None):
+ detect_conflicts=False, allow_rollback=False,
index_deletes=None,
+ index_adds=None, changelog_entries=None):
retry_count = 0
retry_result = None
@@ -297,7 +301,7 @@ class FileStoreCommit:
# No entries to commit (e.g. drop_partitions with no matching
data): skip commit
# to avoid creating manifest/snapshot with empty partition_stats
(causes read errors).
- if not commit_entries and not index_deletes:
+ if not commit_entries and not index_deletes and not index_adds:
break
result = self._try_commit_once(
@@ -310,6 +314,7 @@ class FileStoreCommit:
detect_conflicts=detect_conflicts,
allow_rollback=allow_rollback,
index_deletes=index_deletes,
+ index_adds=index_adds,
)
if result.is_success():
@@ -364,7 +369,8 @@ class FileStoreCommit:
latest_snapshot: Optional[Snapshot],
detect_conflicts: bool = False,
allow_rollback: bool = False,
- index_deletes=None) -> CommitResult:
+ index_deletes=None,
+ index_adds=None) -> CommitResult:
start_millis = int(time.time() * 1000)
if self._is_duplicate_commit(retry_result, latest_snapshot,
commit_identifier, commit_kind):
return SuccessResult()
@@ -466,11 +472,11 @@ class FileStoreCommit:
index_manifest = None
if latest_snapshot and commit_kind == "APPEND":
index_manifest = latest_snapshot.index_manifest
- if index_deletes:
+ if index_deletes or index_adds:
from pypaimon.manifest.index_manifest_file import
IndexManifestFile
previous_index_manifest = index_manifest
- index_manifest = IndexManifestFile(self.table).combine_deletes(
- previous_index_manifest, index_deletes)
+ index_manifest = IndexManifestFile(self.table).combine_changes(
+ previous_index_manifest, index_adds or [], index_deletes
or [])
if index_manifest != previous_index_manifest:
new_index_manifest = index_manifest
@@ -696,6 +702,19 @@ class FileStoreCommit:
except Exception as e:
path_to_delete = file.external_path if file.external_path
else file.file_path
logger.warning(f"Failed to clean up file {path_to_delete}
during abort: {e}")
+ for entry in message.index_adds:
+ try:
+ file_name = entry.index_file.file_name
+ index_path = (
+ entry.index_file.external_path
+ or self.table.path_factory()
+ .global_index_path_factory()
+ .to_path(file_name)
+ )
+ self.table.file_io.delete_quietly(index_path)
+ except Exception as e:
+ logger.warning(
+ f"Failed to clean up index file
{entry.index_file.file_name} during abort: {e}")
def close(self):
"""Close the FileStoreCommit and release resources."""