blob: c2cd24d7ec357d6421d6fdaf72bcd5af90af84cd [file]
# 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