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 0c9811ad8c [python] Support bitmap global index build (#8402)
0c9811ad8c is described below
commit 0c9811ad8cde47f8a610224ca48f241202790bba
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Jul 1 09:28:59 2026 +0800
[python] Support bitmap global index build (#8402)
Support building bitmap global indexes from PyPaimon, matching the Java
bitmap global index file format and sorted-index build path.
---
docs/docs/multimodal-table/global-index.mdx | 6 +-
docs/docs/multimodal-table/global-index/bitmap.mdx | 114 +++++++++
.../pypaimon/common/options/core_options.py | 36 +++
.../pypaimon/globalindex/bitmap/__init__.py | 6 +
.../globalindex/bitmap/bitmap_index_writer.py | 285 +++++++++++++++++++++
.../globalindex/btree/btree_index_writer.py | 74 ++----
.../pypaimon/globalindex/create_global_index.py | 61 +++--
.../pypaimon/globalindex/index_file_utils.py | 111 ++++++++
.../pypaimon/tests/global_index_build_test.py | 93 +++++++
9 files changed, 710 insertions(+), 76 deletions(-)
diff --git a/docs/docs/multimodal-table/global-index.mdx
b/docs/docs/multimodal-table/global-index.mdx
index 6ee0882707..e80f5bce25 100644
--- a/docs/docs/multimodal-table/global-index.mdx
+++ b/docs/docs/multimodal-table/global-index.mdx
@@ -157,9 +157,9 @@ added_files = table.create_global_index(
</Tabs>
-PyPaimon global index build currently supports single-column BTree indexes and
-single-column paimon-vindex IVF vector indexes on tables with row tracking
-enabled.
+PyPaimon global index build currently supports single-column BTree indexes,
+single-column Bitmap indexes, and single-column paimon-vindex IVF vector
indexes
+on tables with row tracking enabled.
Drop index files:
diff --git a/docs/docs/multimodal-table/global-index/bitmap.mdx
b/docs/docs/multimodal-table/global-index/bitmap.mdx
index 453d8dbf00..154110af28 100644
--- a/docs/docs/multimodal-table/global-index/bitmap.mdx
+++ b/docs/docs/multimodal-table/global-index/bitmap.mdx
@@ -3,6 +3,9 @@ title: "Bitmap Index"
sidebar_position: 2
---
+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
@@ -56,6 +59,10 @@ exceeded, Paimon falls back to other matching indexes or
regular table scans.
## Build Bitmap Index
+<Tabs groupId="bitmap-build">
+
+<TabItem value="sql" label="SQL">
+
```sql
-- Create bitmap index on 'tag' column
CALL sys.create_global_index(
@@ -76,12 +83,90 @@ CALL sys.create_global_index(
);
```
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+table = catalog.get_table("db.my_table")
+
+# Create bitmap index on 'tag' column.
+added_files = table.create_global_index("tag", index_type="bitmap")
+print(added_files)
+```
+
+The API returns the number of committed index files. You can pass bitmap build
+options and restrict the build to selected partitions:
+
+```python
+added_files = table.create_global_index(
+ "tag",
+ index_type="bitmap",
+ partitions=[{"dt": "2026-06-18"}, {"dt": "2026-06-19"}],
+ options={
+ "sorted-index.records-per-range": "10000000",
+ "bitmap-index.dictionary-block-size": "16 kb",
+ },
+)
+print(added_files)
+```
+
+</TabItem>
+
+</Tabs>
+
Bitmap indexes share the sorted index build path with BTree indexes. Use
`sorted-index.records-per-range` to control the expected records per generated
index
file, and `sorted-index.build.max-parallelism` to cap Flink or Spark build
parallelism. The legacy `btree-index.records-per-range` and
`btree-index.build.max-parallelism` keys are still recognized as fallback keys.
+PyPaimon bitmap index build currently supports `bitmap-index.compression` set
to
+`none`, which is also the default. Use SQL if you need to build bitmap index
files
+with compressed dictionary blocks.
+
+## Drop Bitmap Index
+
+<Tabs groupId="bitmap-drop">
+
+<TabItem value="sql" label="SQL">
+
+```sql
+CALL sys.drop_global_index(
+ table => 'db.my_table',
+ index_column => 'tag',
+ index_type => 'bitmap'
+);
+```
+
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+table = catalog.get_table("db.my_table")
+
+dropped_files = table.drop_global_index("tag", index_type="bitmap")
+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(
+ "tag",
+ index_type="bitmap",
+ partitions=[{"dt": "2026-06-18"}, {"dt": "2026-06-19"}],
+ dry_run=True,
+)
+print(matched_files)
+```
+
+</TabItem>
+
+</Tabs>
+
## Bitmap Options
| Option | Default | Description |
@@ -98,10 +183,39 @@ parallelism. The legacy `btree-index.records-per-range` and
Once a bitmap index is built, it is automatically used during scan when a
filter
predicate matches the indexed column.
+<Tabs groupId="bitmap-search">
+
+<TabItem value="sql" label="SQL">
+
```sql
SELECT * FROM my_table WHERE tag IN ('vip', 'trial');
```
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+from pypaimon.common.predicate_builder import PredicateBuilder
+
+table = catalog.get_table("db.my_table")
+
+read_builder = table.new_read_builder()
+read_builder = read_builder.with_filter(
+ PredicateBuilder(table.fields)
+ .is_in("tag", ["vip", "trial"])
+)
+
+scan = read_builder.new_scan()
+read = read_builder.new_read()
+pa_table = read.to_arrow(scan.plan().splits())
+print(pa_table)
+```
+
+</TabItem>
+
+</Tabs>
+
For complement predicates such as `tag != 'blocked'` or
`tag NOT IN ('blocked', 'test')`, bitmap index evaluates the complement
against each
index file's own non-null row-id bitmap. This keeps results correct when one
logical
diff --git a/paimon-python/pypaimon/common/options/core_options.py
b/paimon-python/pypaimon/common/options/core_options.py
index d76785198b..2dc8049601 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -725,6 +725,31 @@ class CoreOptions:
)
)
+ BITMAP_INDEX_DICTIONARY_BLOCK_SIZE: ConfigOption[MemorySize] = (
+ ConfigOptions.key("bitmap-index.dictionary-block-size")
+ .memory_type()
+ .default_value(MemorySize.of_kibi_bytes(16))
+ .with_description(
+ "The target dictionary block size for bitmap global indexes."
+ )
+ )
+
+ BITMAP_INDEX_COMPRESSION: ConfigOption[str] = (
+ ConfigOptions.key("bitmap-index.compression")
+ .string_type()
+ .default_value("none")
+ .with_description("Compression algorithm for bitmap global index
blocks.")
+ )
+
+ BITMAP_INDEX_COMPRESSION_LEVEL: ConfigOption[int] = (
+ ConfigOptions.key("bitmap-index.compression-level")
+ .int_type()
+ .default_value(1)
+ .with_description(
+ "Compression level for bitmap global index block compression."
+ )
+ )
+
LOCAL_CACHE_ENABLED: ConfigOption[bool] = (
ConfigOptions.key("local-cache.enabled")
.boolean_type()
@@ -1225,6 +1250,17 @@ class CoreOptions:
CoreOptions.BITMAP_INDEX_FALLBACK_SCAN_MAX_SIZE
).get_bytes()
+ def bitmap_index_dictionary_block_size(self) -> int:
+ return self.options.get(
+ CoreOptions.BITMAP_INDEX_DICTIONARY_BLOCK_SIZE
+ ).get_bytes()
+
+ def bitmap_index_compression(self) -> str:
+ return self.options.get(CoreOptions.BITMAP_INDEX_COMPRESSION)
+
+ def bitmap_index_compression_level(self) -> int:
+ return self.options.get(CoreOptions.BITMAP_INDEX_COMPRESSION_LEVEL)
+
def local_cache_enabled(self) -> bool:
return self.options.get(CoreOptions.LOCAL_CACHE_ENABLED)
diff --git a/paimon-python/pypaimon/globalindex/bitmap/__init__.py
b/paimon-python/pypaimon/globalindex/bitmap/__init__.py
index 4e37cce1eb..d3977a2c65 100644
--- a/paimon-python/pypaimon/globalindex/bitmap/__init__.py
+++ b/paimon-python/pypaimon/globalindex/bitmap/__init__.py
@@ -18,9 +18,15 @@
"""Bitmap global index support."""
from pypaimon.globalindex.bitmap.bitmap_index_reader import BitmapIndexReader
+from pypaimon.globalindex.bitmap.bitmap_index_writer import (
+ BITMAP_IDENTIFIER,
+ BitmapIndexWriter,
+)
from pypaimon.globalindex.bitmap.lazy_filtered_bitmap_reader import
LazyFilteredBitmapReader
__all__ = [
+ 'BITMAP_IDENTIFIER',
'BitmapIndexReader',
+ 'BitmapIndexWriter',
'LazyFilteredBitmapReader',
]
diff --git a/paimon-python/pypaimon/globalindex/bitmap/bitmap_index_writer.py
b/paimon-python/pypaimon/globalindex/bitmap/bitmap_index_writer.py
new file mode 100644
index 0000000000..c2cd24d7ec
--- /dev/null
+++ b/paimon-python/pypaimon/globalindex/bitmap/bitmap_index_writer.py
@@ -0,0 +1,285 @@
+# 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.
+
+"""Bitmap global index writer compatible with Java's bitmap format."""
+
+from dataclasses import dataclass
+import struct
+from typing import Dict, List
+
+from pypaimon.globalindex.index_file_utils import (
+ BlockInfo,
+ PositionOutput,
+ new_global_index_file_name,
+ var_len_int_size,
+ var_len_long_size,
+ write_uncompressed_block,
+ write_var_len_int,
+ write_var_len_long,
+)
+from pypaimon.globalindex.result_entry import ResultEntry
+from pypaimon.globalindex.sorted_index_file_meta import SortedIndexFileMeta
+from pypaimon.utils.roaring_bitmap import RoaringBitmap64
+
+
+BITMAP_IDENTIFIER = "bitmap"
+_BITMAP_FOOTER_LENGTH = 48
+_BITMAP_MAGIC = 0x42474958
+_BITMAP_VERSION = 1
+_DEFAULT_DICTIONARY_BLOCK_SIZE = 16 * 1024
+
+
+@dataclass(frozen=True)
+class _DictionaryBlockMeta(BlockInfo):
+ first_key: bytes
+
+
+@dataclass(frozen=True)
+class _DictionaryEntry:
+ key: bytes
+ bitmap_block: BlockInfo
+
+ def estimated_size(self) -> int:
+ return (
+ var_len_int_size(len(self.key))
+ + len(self.key)
+ + var_len_long_size(self.bitmap_block.offset)
+ + var_len_int_size(self.bitmap_block.length)
+ )
+
+
+class _DictionaryBlockBuilder:
+ def __init__(self):
+ self.entries: List[_DictionaryEntry] = []
+ self._entries_size = 0
+
+ def has_entries(self) -> bool:
+ return len(self.entries) > 0
+
+ def estimated_size(self) -> int:
+ return var_len_int_size(len(self.entries)) + self._entries_size
+
+ def estimated_size_after(self, entry: _DictionaryEntry) -> int:
+ return (
+ var_len_int_size(len(self.entries) + 1)
+ + self._entries_size
+ + entry.estimated_size()
+ )
+
+ def add(self, entry: _DictionaryEntry) -> None:
+ self.entries.append(entry)
+ self._entries_size += entry.estimated_size()
+
+ def first_key(self) -> bytes:
+ return self.entries[0].key
+
+
+class BitmapIndexWriter:
+ """Writer for one bitmap global index file.
+
+ Row IDs are local to the manifest row range, matching the Java writer
+ contract used by sorted global index builders.
+ """
+
+ def __init__(
+ self,
+ file_io,
+ index_path: str,
+ key_serializer,
+ dictionary_block_size: int = _DEFAULT_DICTIONARY_BLOCK_SIZE,
+ compression: str = "none",
+ ):
+ if dictionary_block_size <= 0:
+ raise ValueError("bitmap-index.dictionary-block-size must be
positive.")
+ if compression is None:
+ compression = "none"
+ compression = str(compression).lower().strip()
+ if compression != "none":
+ raise ValueError(
+ "Python bitmap global index build currently supports only "
+ "bitmap-index.compression=none, got '%s'." % compression
+ )
+
+ self.file_name = new_global_index_file_name(BITMAP_IDENTIFIER)
+ self._file_io = file_io
+ self._index_path = index_path.rstrip("/")
+ self._key_serializer = key_serializer
+ self._comparator = key_serializer.create_comparator()
+ self._dictionary_block_size = dictionary_block_size
+ self._bitmaps: Dict[bytes, RoaringBitmap64] = {}
+ self._null_rows = RoaringBitmap64()
+ self._non_null_rows = RoaringBitmap64()
+ self._row_count = 0
+ self._first_key = None
+ self._last_key = None
+ self._closed = False
+
+ def write(self, key, row_id: int) -> None:
+ self._row_count += 1
+ if key is None:
+ self._null_rows.add(row_id)
+ return
+
+ self._non_null_rows.add(row_id)
+ self._update_min_max(key)
+ serialized_key = self._key_serializer.serialize(key)
+ bitmap = self._bitmaps.get(serialized_key)
+ if bitmap is None:
+ bitmap = RoaringBitmap64()
+ self._bitmaps[serialized_key] = bitmap
+ bitmap.add(row_id)
+
+ def finish(self) -> List[ResultEntry]:
+ if self._closed:
+ raise RuntimeError("BitmapIndexWriter is already closed.")
+ self._closed = True
+ if self._row_count == 0:
+ return []
+
+ self._file_io.check_or_mkdirs(self._index_path)
+ output = PositionOutput(
+ self._file_io.new_output_stream(self._file_path()))
+ try:
+ self._write(output)
+ output.close()
+ except Exception:
+ self._file_io.delete_quietly(self._file_path())
+ raise
+
+ 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),
+ not self._null_rows.is_empty(),
+ ).serialize()
+ return [ResultEntry(self.file_name, self._row_count, meta)]
+
+ def _write(self, output: PositionOutput) -> None:
+ null_rows_block = _write_bitmap_block(output, self._null_rows)
+ non_null_rows_block = _write_bitmap_block(output, self._non_null_rows)
+ dictionary_blocks, value_count =
self._write_dictionary_and_bitmap_blocks(
+ output)
+ index_block = _write_index_block(output, dictionary_blocks)
+ output.write(
+ _write_footer(
+ null_rows_block,
+ non_null_rows_block,
+ index_block,
+ value_count,
+ )
+ )
+
+ def _write_dictionary_and_bitmap_blocks(self, output: PositionOutput):
+ dictionary_blocks = []
+ current = _DictionaryBlockBuilder()
+ value_count = 0
+
+ for serialized_key, bitmap in sorted(self._bitmaps.items()):
+ bitmap_block = _write_bitmap_block(output, bitmap)
+ entry = _DictionaryEntry(serialized_key, bitmap_block)
+ if (
+ current.has_entries()
+ and current.estimated_size_after(entry) >
self._dictionary_block_size
+ ):
+ dictionary_blocks.append(_write_dictionary_block(output,
current))
+ current = _DictionaryBlockBuilder()
+ current.add(entry)
+ value_count += 1
+
+ if current.has_entries():
+ dictionary_blocks.append(_write_dictionary_block(output, current))
+ return dictionary_blocks, value_count
+
+ def _update_min_max(self, key) -> None:
+ if self._first_key is None or self._comparator(key, self._first_key) <
0:
+ self._first_key = key
+ if self._last_key is None or self._comparator(key, self._last_key) > 0:
+ self._last_key = key
+
+ def _file_path(self) -> str:
+ return "%s/%s" % (self._index_path, self.file_name)
+
+
+def _write_dictionary_block(
+ output: PositionOutput,
+ block: _DictionaryBlockBuilder,
+) -> _DictionaryBlockMeta:
+ data = bytearray()
+ data.extend(write_var_len_int(len(block.entries)))
+ for entry in block.entries:
+ data.extend(write_var_len_int(len(entry.key)))
+ data.extend(entry.key)
+ data.extend(write_var_len_long(entry.bitmap_block.offset))
+ data.extend(write_var_len_int(entry.bitmap_block.length))
+ block_info = _write_compressible_block(output, bytes(data))
+ return _DictionaryBlockMeta(
+ block_info.offset,
+ block_info.length,
+ block.first_key(),
+ )
+
+
+def _write_index_block(
+ output: PositionOutput,
+ blocks: List[_DictionaryBlockMeta],
+) -> BlockInfo:
+ data = bytearray()
+ data.extend(write_var_len_int(len(blocks)))
+ for block in blocks:
+ data.extend(write_var_len_int(len(block.first_key)))
+ data.extend(block.first_key)
+ data.extend(write_var_len_long(block.offset))
+ data.extend(write_var_len_int(block.length))
+ return _write_compressible_block(output, bytes(data))
+
+
+def _write_bitmap_block(
+ output: PositionOutput,
+ bitmap: RoaringBitmap64,
+) -> BlockInfo:
+ data = bitmap.serialize()
+ offset = output.pos
+ output.write(data)
+ return BlockInfo(offset, len(data))
+
+
+def _write_compressible_block(output: PositionOutput, data: bytes) ->
BlockInfo:
+ return write_uncompressed_block(output, data)
+
+
+def _write_footer(
+ null_rows_block: BlockInfo,
+ non_null_rows_block: BlockInfo,
+ index_block: BlockInfo,
+ value_count: int,
+) -> bytes:
+ result = struct.pack(
+ ">q i q i q i i i i",
+ null_rows_block.offset,
+ null_rows_block.length,
+ non_null_rows_block.offset,
+ non_null_rows_block.length,
+ index_block.offset,
+ index_block.length,
+ value_count,
+ _BITMAP_VERSION,
+ _BITMAP_MAGIC,
+ )
+ if len(result) != _BITMAP_FOOTER_LENGTH:
+ raise AssertionError("Unexpected bitmap footer length: %s" %
len(result))
+ return result
diff --git a/paimon-python/pypaimon/globalindex/btree/btree_index_writer.py
b/paimon-python/pypaimon/globalindex/btree/btree_index_writer.py
index 85be3fb2ee..be3dd7014a 100644
--- a/paimon-python/pypaimon/globalindex/btree/btree_index_writer.py
+++ b/paimon-python/pypaimon/globalindex/btree/btree_index_writer.py
@@ -18,13 +18,15 @@
"""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.index_file_utils import (
+ PositionOutput,
+ new_global_index_file_name,
+ write_uncompressed_block,
+ write_var_len_int,
+ write_var_len_long,
)
from pypaimon.globalindex.result_entry import ResultEntry
from pypaimon.globalindex.sorted_index_file_meta import SortedIndexFileMeta
@@ -39,47 +41,8 @@ _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()
+ return write_var_len_long(offset) + write_var_len_int(size)
class _BlockWriter:
@@ -97,9 +60,9 @@ class _BlockWriter:
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(write_var_len_int(len(key)))
self._block.extend(key)
- self._block.extend(_write_var_len_int(len(value)))
+ self._block.extend(write_var_len_int(len(value)))
self._block.extend(value)
end_position = len(self._block)
@@ -137,7 +100,7 @@ class _BlockWriter:
class _SstFileWriter:
- def __init__(self, out: _PositionOutput, block_size: int):
+ def __init__(self, out: PositionOutput, block_size: int):
self._out = out
self._block_size = block_size
self._data_block_writer = _BlockWriter()
@@ -169,13 +132,9 @@ class _SstFileWriter:
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_info = write_uncompressed_block(self._out, block)
block_writer.reset()
- return block_offset, len(block)
+ return block_info.offset, block_info.length
class BTreeIndexWriter:
@@ -192,8 +151,7 @@ class BTreeIndexWriter:
key_serializer,
block_size: int = 64 * 1024,
):
- self.file_name = (
- "%s-global-index-%s.index" % (BTREE_IDENTIFIER, uuid.uuid4()))
+ self.file_name = new_global_index_file_name(BTREE_IDENTIFIER)
self._file_io = file_io
self._index_path = index_path.rstrip('/')
self._key_serializer = key_serializer
@@ -206,7 +164,7 @@ class BTreeIndexWriter:
self._closed = False
self._file_io.check_or_mkdirs(self._index_path)
- self._output = _PositionOutput(
+ self._output = PositionOutput(
self._file_io.new_output_stream(self._file_path()))
self._sst = _SstFileWriter(self._output, block_size)
@@ -262,9 +220,9 @@ class BTreeIndexWriter:
return
value = bytearray()
- value.extend(_write_var_len_int(len(self._current_row_ids)))
+ 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))
+ value.extend(write_var_len_long(row_id))
self._current_row_ids = []
self._sst.put(self._key_serializer.serialize(self._last_key),
bytes(value))
diff --git a/paimon-python/pypaimon/globalindex/create_global_index.py
b/paimon-python/pypaimon/globalindex/create_global_index.py
index b1c577005b..db184ddad9 100644
--- a/paimon-python/pypaimon/globalindex/create_global_index.py
+++ b/paimon-python/pypaimon/globalindex/create_global_index.py
@@ -29,6 +29,10 @@ from pypaimon.globalindex.btree.btree_index_writer import (
BTREE_IDENTIFIER,
BTreeIndexWriter,
)
+from pypaimon.globalindex.bitmap.bitmap_index_writer import (
+ BITMAP_IDENTIFIER,
+ BitmapIndexWriter,
+)
from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
from pypaimon.globalindex.key_serializer import create_serializer
from pypaimon.globalindex.vindex.vindex_vector_global_index_reader import (
@@ -81,6 +85,10 @@ def create_global_index(
return sum(len(message.index_adds) for message in messages)
+_SORTED_INDEX_IDENTIFIERS = (BTREE_IDENTIFIER, BITMAP_IDENTIFIER)
+_SORTED_INDEX_RECORDS_PER_RANGE_FLOATING = 1.2
+
+
class GlobalIndexBuilder:
"""Small Python builder for global indexes."""
@@ -101,10 +109,13 @@ class GlobalIndexBuilder:
self._options = _merged_options(table, options)
self._core_options = CoreOptions(self._options)
- if self._index_type != BTREE_IDENTIFIER and self._index_type not in
VINDEX_IDENTIFIERS:
+ if (
+ self._index_type not in _SORTED_INDEX_IDENTIFIERS
+ and self._index_type not in VINDEX_IDENTIFIERS
+ ):
raise ValueError(
- "Python global index build currently supports '%s' and %s, got
'%s'."
- % (BTREE_IDENTIFIER, VINDEX_IDENTIFIERS, index_type)
+ "Python global index build currently supports %s and %s, got
'%s'."
+ % (_SORTED_INDEX_IDENTIFIERS, VINDEX_IDENTIFIERS, index_type)
)
if len(self._index_columns) != 1:
raise ValueError(
@@ -154,18 +165,23 @@ class GlobalIndexBuilder:
index_path_factory =
self._table.path_factory().global_index_path_factory()
index_path = index_path_factory.global_index_root_path()
- if self._index_type == BTREE_IDENTIFIER:
- return self._build_btree(splits, index_field, table_read,
index_path)
+ if self._index_type in _SORTED_INDEX_IDENTIFIERS:
+ return self._build_sorted_index(
+ splits, index_field, table_read, index_path)
return self._build_vindex(splits, index_field, table_read, index_path)
- def _build_btree(
+ def _build_sorted_index(
self, splits, index_field, table_read, index_path: str
) -> List[CommitMessage]:
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:
+ configured_records_per_range = (
+ self._core_options.sorted_index_records_per_range())
+ if configured_records_per_range <= 0:
raise ValueError("sorted-index.records-per-range must be
positive.")
+ records_per_range = int(
+ configured_records_per_range
+ * _SORTED_INDEX_RECORDS_PER_RANGE_FLOATING
+ )
messages = []
for split in _split_by_contiguous_row_range(splits):
@@ -183,12 +199,8 @@ class GlobalIndexBuilder:
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,
- )
+ writer = self._create_sorted_index_writer(
+ index_path, key_serializer)
for key, row_id in chunk:
writer.write(key, row_id - row_range.from_)
result_entries = writer.finish()
@@ -213,6 +225,25 @@ class GlobalIndexBuilder:
)
return messages
+ def _create_sorted_index_writer(self, index_path: str, key_serializer):
+ if self._index_type == BTREE_IDENTIFIER:
+ return BTreeIndexWriter(
+ self._table.file_io,
+ index_path,
+ key_serializer,
+ block_size=self._core_options.btree_index_block_size(),
+ )
+ if self._index_type == BITMAP_IDENTIFIER:
+ return BitmapIndexWriter(
+ self._table.file_io,
+ index_path,
+ key_serializer,
+ dictionary_block_size=(
+ self._core_options.bitmap_index_dictionary_block_size()),
+ compression=self._core_options.bitmap_index_compression(),
+ )
+ raise ValueError("Unsupported sorted global index type: %s" %
self._index_type)
+
def _build_vindex(
self, splits, index_field, table_read, index_path: str
) -> List[CommitMessage]:
diff --git a/paimon-python/pypaimon/globalindex/index_file_utils.py
b/paimon-python/pypaimon/globalindex/index_file_utils.py
new file mode 100644
index 0000000000..a27a923b04
--- /dev/null
+++ b/paimon-python/pypaimon/globalindex/index_file_utils.py
@@ -0,0 +1,111 @@
+# 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.
+
+"""Shared helpers for Java-compatible global index files."""
+
+from dataclasses import dataclass
+import struct
+import uuid
+
+from pypaimon.globalindex.block_compression import COMPRESSION_NONE, crc32c
+
+
+@dataclass(frozen=True)
+class BlockInfo:
+ offset: int
+ length: int
+
+
+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()
+
+
+def new_global_index_file_name(prefix: str) -> str:
+ return "%s-global-index-%s.index" % (prefix, uuid.uuid4())
+
+
+def write_uncompressed_block(output: PositionOutput, data: bytes) -> BlockInfo:
+ offset = output.pos
+ output.write(data)
+ write_block_trailer(output, data, COMPRESSION_NONE)
+ return BlockInfo(offset, len(data))
+
+
+def write_block_trailer(
+ output: PositionOutput,
+ data: bytes,
+ compression_type: int,
+) -> None:
+ output.write(
+ struct.pack("<B", compression_type)
+ + struct.pack("<I", crc32c(data, compression_type))
+ )
+
+
+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 var_len_int_size(value: int) -> int:
+ if value < 0:
+ raise ValueError("negative value: v=%s" % value)
+ size = 1
+ while value & ~0x7F:
+ value >>= 7
+ size += 1
+ return size
+
+
+def var_len_long_size(value: int) -> int:
+ if value < 0:
+ raise ValueError("negative value: v=%s" % value)
+ size = 1
+ while value & ~0x7F:
+ value >>= 7
+ size += 1
+ return size
diff --git a/paimon-python/pypaimon/tests/global_index_build_test.py
b/paimon-python/pypaimon/tests/global_index_build_test.py
index 00279d01d7..957f6583be 100644
--- a/paimon-python/pypaimon/tests/global_index_build_test.py
+++ b/paimon-python/pypaimon/tests/global_index_build_test.py
@@ -221,6 +221,99 @@ class GlobalIndexBuildTest(
index_read_builder.new_scan().plan().splits())
self.assertEqual(0, index_table.num_rows)
+ def test_create_bitmap_global_index_from_python(self):
+ table = self._create_table()
+ self._write_arrow(table, pa.table(
+ {
+ 'id': [1, 2, 3, 4, 5],
+ 'name': ['a', 'b', 'c', 'd', 'e'],
+ 'age': [10, 20, 30, 40, 50],
+ 'city': ['vip', 'trial', None, 'vip', 'blocked'],
+ },
+ schema=self.pa_schema,
+ ))
+
+ added = table.create_global_index(
+ 'city',
+ index_type='bitmap',
+ options={
+ 'sorted-index.records-per-range': '2',
+ 'bitmap-index.dictionary-block-size': '1 b',
+ },
+ )
+
+ self.assertEqual(3, added)
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ entries = IndexFileHandler(table).scan(snapshot)
+ self.assertEqual(3, len(entries))
+ self.assertEqual({'bitmap'}, {e.index_file.index_type for e in
entries})
+ self.assertEqual([1, 2, 2],
+ sorted(e.index_file.row_count for e in entries))
+ self.assertEqual(
+ {0},
+ {e.index_file.global_index_meta.row_range_start for e in entries},
+ )
+ self.assertEqual(
+ {4},
+ {e.index_file.global_index_meta.row_range_end for e in entries},
+ )
+
+ read_builder = table.new_read_builder()
+ predicate_builder = read_builder.new_predicate_builder()
+ cases = [
+ (predicate_builder.is_in('city', ['vip', 'trial']),
+ [Range(0, 1), Range(3, 3)]),
+ (predicate_builder.is_null('city'), [Range(2, 2)]),
+ (predicate_builder.not_equal('city', 'blocked'),
+ [Range(0, 1), Range(3, 3)]),
+ ]
+ for predicate, expected in cases:
+ with GlobalIndexScanner.create(
+ table,
+ predicate=predicate,
+ snapshot=snapshot) as scanner:
+ result = scanner.scan(predicate)
+ self.assertEqual(expected, result.results().to_range_list())
+
+ def test_create_bitmap_global_index_rejects_unsupported_compression(self):
+ table = self._create_table()
+ self._write_arrow(table, pa.table(
+ {
+ 'id': [1],
+ 'name': ['a'],
+ 'age': [10],
+ 'city': ['vip'],
+ },
+ schema=self.pa_schema,
+ ))
+
+ with self.assertRaisesRegex(ValueError,
'bitmap-index.compression=none'):
+ table.create_global_index(
+ 'city',
+ index_type='bitmap',
+ options={'bitmap-index.compression': 'lz4'},
+ )
+
+ def test_sorted_index_records_per_range_matches_java_floating_factor(self):
+ table = self._create_table()
+ rows = list(range(12))
+ self._write_arrow(table, pa.table(
+ {
+ 'id': rows,
+ 'name': ['n%s' % i for i in rows],
+ 'age': rows,
+ 'city': ['c%s' % i for i in rows],
+ },
+ schema=self.pa_schema,
+ ))
+
+ added = table.create_global_index(
+ 'id',
+ options={'sorted-index.records-per-range': '10'},
+ )
+
+ self.assertEqual(1, added)
+
def test_create_global_index_uses_external_path(self):
external_root = 'file://%s' % os.path.join(
self.tempdir, 'global-index-external')