blob: d65355310db2c51750e9d123a5be282888c25a2b [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.
import struct
from decimal import Decimal, localcontext
from typing import Any, List, Optional, Tuple
import pyarrow as pa
import pyarrow.dataset as ds
from pyarrow import RecordBatch
from pypaimon.common.delta_varint_compressor import DeltaVarintCompressor
from pypaimon.common.file_io import FileIO
from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
from pypaimon.schema.data_types import (
ArrayType, DataField, MapType, MultisetType, PyarrowFieldParser, RowType, VectorType, AtomicType
)
from pypaimon.table.special_fields import SpecialFields
FOOTER_SIZE = 32
MAGIC = 0x524F5753 # "ROWS"
VERSION = 1
def _special_field(name: str):
if name == SpecialFields.ROW_ID.name:
return SpecialFields.ROW_ID
if name == SpecialFields.SEQUENCE_NUMBER.name:
return SpecialFields.SEQUENCE_NUMBER
if name == SpecialFields.VALUE_KIND.name:
return SpecialFields.VALUE_KIND
return None
class FormatRowReader(RecordBatchReader):
def __init__(self, file_io: FileIO, file_path: str, read_fields: List[str],
full_fields: List[DataField], push_down_predicate: Any,
batch_size: int = 1024, row_indices: Optional[List[int]] = None):
self._file_io = file_io
self._file_path = file_path
self._push_down_predicate = push_down_predicate
self._batch_size = batch_size
self._file_size = file_io.get_file_size(file_path)
full_fields_map = {field.name: field for field in full_fields}
self._read_field_names = list(read_fields)
self._projected_fields = [full_fields_map[name] for name in read_fields
if name in full_fields_map]
self._missing_fields = [name for name in read_fields
if name not in full_fields_map]
self._all_fields = full_fields
self._physical_schema = PyarrowFieldParser.from_paimon_schema(self._projected_fields)
self._block_compressed_sizes: List[int] = []
self._block_uncompressed_sizes: List[int] = []
self._block_row_starts: List[int] = []
self._block_offsets: List[int] = []
self._total_row_count = 0
self._block_count = 0
self._current_block_idx = 0
self._row_indices = row_indices
self._row_indices_pos = 0
self._read_metadata()
if self._row_indices is not None:
self._blocks_to_read = self._compute_blocks_for_indices()
self._blocks_to_read_pos = 0
else:
self._blocks_to_read = None
def _compute_blocks_for_indices(self) -> List[tuple]:
"""Group row_indices by block. Returns list of (block_idx, [local_row_offsets])."""
import bisect
result = []
row_starts = self._block_row_starts
for idx in self._row_indices:
block_idx = bisect.bisect_right(row_starts, idx) - 1
local_row = idx - row_starts[block_idx]
if result and result[-1][0] == block_idx:
result[-1][1].append(local_row)
else:
result.append((block_idx, [local_row]))
return result
def read_arrow_batch(self) -> Optional[RecordBatch]:
if self._row_indices is not None:
return self._read_batch_indexed()
return self._read_batch_sequential()
def _read_batch_sequential(self) -> Optional[RecordBatch]:
if self._current_block_idx >= self._block_count:
return None
block_data = self._read_and_decompress_block(self._current_block_idx)
self._current_block_idx += 1
columns, row_count = self._decode_block(block_data)
if row_count == 0:
return None
table = self._build_table(columns, row_count)
if self._push_down_predicate is not None:
dataset = ds.InMemoryDataset(table)
scanner = dataset.scanner(filter=self._push_down_predicate)
table = scanner.to_table().combine_chunks()
if table.num_rows == 0:
return self._read_batch_sequential()
return table.to_batches()[0]
def _read_batch_indexed(self) -> Optional[RecordBatch]:
if self._blocks_to_read_pos >= len(self._blocks_to_read):
return None
block_idx, local_rows = self._blocks_to_read[self._blocks_to_read_pos]
self._blocks_to_read_pos += 1
block_data = self._read_and_decompress_block(block_idx)
columns, row_count = self._decode_block(block_data, row_filter=local_rows)
if row_count == 0:
return self._read_batch_indexed()
table = self._build_table(columns, row_count)
if self._push_down_predicate is not None:
dataset = ds.InMemoryDataset(table)
scanner = dataset.scanner(filter=self._push_down_predicate)
table = scanner.to_table().combine_chunks()
if table.num_rows == 0:
return self._read_batch_indexed()
return table.to_batches()[0]
def close(self):
pass
def _read_metadata(self):
if self._file_size < FOOTER_SIZE:
raise IOError(f"Invalid row file: {self._file_size} bytes hold no {FOOTER_SIZE}-byte footer")
with self._file_io.new_input_stream(self._file_path) as f:
f.seek(self._file_size - FOOTER_SIZE)
footer_bytes = f.read(FOOTER_SIZE)
if len(footer_bytes) != FOOTER_SIZE:
raise IOError("Invalid row file: cannot read footer")
magic = struct.unpack_from('<I', footer_bytes, 28)[0]
if magic != MAGIC:
raise IOError(f"Invalid row file magic: expected 0x{MAGIC:08X}, got 0x{magic:08X}")
version = footer_bytes[24]
if version != VERSION:
raise IOError(f"Unsupported row file version: {version}")
self._total_row_count = struct.unpack_from('<q', footer_bytes, 0)[0]
self._block_count = struct.unpack_from('<i', footer_bytes, 8)[0]
self._index_offset = struct.unpack_from('<q', footer_bytes, 12)[0]
index_offset = self._index_offset
index_length = struct.unpack_from('<i', footer_bytes, 20)[0]
# the block index has to lie inside the file and ahead of the footer, and these are the
# numbers the seek and the read below are given
if (index_offset < 0 or index_length < 0
or index_offset > self._file_size - FOOTER_SIZE - index_length):
raise IOError(f"Invalid row file block index location: offset {index_offset}, "
f"length {index_length}, in a file of {self._file_size} bytes")
if self._block_count < 0:
raise IOError(f"Invalid row file block count: {self._block_count}")
if self._total_row_count < 0:
raise IOError(f"Invalid row file row count: {self._total_row_count}")
with self._file_io.new_input_stream(self._file_path) as f:
f.seek(index_offset)
index_bytes = f.read(index_length)
if len(index_bytes) != index_length:
raise IOError("Invalid row file: cannot read block index")
self._parse_block_index(index_bytes)
def _parse_block_index(self, index_data: bytes):
pos = 0
len1, consumed = _decode_var_int(index_data, pos)
pos += consumed
self._block_compressed_sizes = DeltaVarintCompressor.decompress(
self._chunk(index_data, pos, len1))
pos += len1
len2, consumed = _decode_var_int(index_data, pos)
pos += consumed
self._block_uncompressed_sizes = DeltaVarintCompressor.decompress(
self._chunk(index_data, pos, len2))
pos += len2
len3, consumed = _decode_var_int(index_data, pos)
pos += consumed
self._block_row_starts = DeltaVarintCompressor.decompress(
self._chunk(index_data, pos, len3))
self._validate_block_index()
offset = 0
self._block_offsets = []
for size in self._block_compressed_sizes:
self._block_offsets.append(offset)
offset += size
@staticmethod
def _chunk(index_data: bytes, pos: int, length: int) -> bytes:
# slicing clamps, and DeltaVarintCompressor.decompress stops at a truncated varint without
# raising, so an out-of-range length would silently shorten one array
if length < 0 or pos + length > len(index_data):
raise IOError(f"Invalid row file block index: a {length}-byte array at offset {pos} "
f"does not fit in {len(index_data)} bytes")
return index_data[pos:pos + length]
def _validate_block_index(self):
"""Cross-check the index against the footer, as the row format spec requires.
Blocks are written contiguously from position 0 and the index follows the last one, so the
compressed sizes sum to exactly index_offset. Row starts have to start at 0 and increase
strictly because _compute_blocks_for_indices bisects them: a first start past 0 makes
bisect_right return 0 for the rows before it, so block_idx is -1, row_starts[-1] is the last
start, and the local row goes negative — this reader would decode a row from the wrong place
in the block rather than skip it.
"""
counts = (len(self._block_compressed_sizes), len(self._block_uncompressed_sizes),
len(self._block_row_starts))
if len(set(counts)) != 1:
raise IOError(f"Row file block index arrays disagree on the block count: "
f"{counts[0]} compressed sizes, {counts[1]} uncompressed sizes, "
f"{counts[2]} row starts")
if counts[0] != self._block_count:
raise IOError(f"Row file block index holds {counts[0]} blocks, but the footer "
f"declares {self._block_count}")
blocks_end = 0
for i, size in enumerate(self._block_compressed_sizes):
if size < 0:
raise IOError(f"Row file block {i} has a negative compressed size {size}")
blocks_end += size
if blocks_end != self._index_offset:
raise IOError(f"Row file blocks end at {blocks_end}, but the footer puts the "
f"block index at {self._index_offset}")
for i, size in enumerate(self._block_uncompressed_sizes):
# nothing in the footer bounds this one, and it sizes the decompression buffer
if size < 0:
raise IOError(f"Row file block {i} has a negative uncompressed size {size}")
if self._block_count == 0:
if self._total_row_count != 0:
raise IOError(f"Row file block index is empty, but the footer declares "
f"{self._total_row_count} rows")
return
if self._block_row_starts[0] != 0:
raise IOError(f"Row file block 0 starts at row {self._block_row_starts[0]}, "
f"so rows before it are unreachable")
for i in range(1, self._block_count):
if self._block_row_starts[i] <= self._block_row_starts[i - 1]:
raise IOError(f"Row file block {i} starts at row {self._block_row_starts[i]}, "
f"not after block {i - 1} at row {self._block_row_starts[i - 1]}")
if self._block_row_starts[-1] >= self._total_row_count:
raise IOError(f"Row file block {self._block_count - 1} starts at row "
f"{self._block_row_starts[-1]}, which the declared row count "
f"{self._total_row_count} does not reach")
def _read_and_decompress_block(self, block_idx: int) -> bytes:
import zstandard as zstd
offset = self._block_offsets[block_idx]
compressed_size = self._block_compressed_sizes[block_idx]
with self._file_io.new_input_stream(self._file_path) as f:
f.seek(offset)
compressed_data = f.read(compressed_size)
decompressor = zstd.ZstdDecompressor()
uncompressed_size = self._block_uncompressed_sizes[block_idx]
return decompressor.decompress(compressed_data, max_output_size=uncompressed_size)
def _build_table(self, columns: List[List], row_count: int) -> pa.Table:
pydict = {
field.name: columns[i]
for i, field in enumerate(self._projected_fields)
}
if not self._missing_fields:
return pa.Table.from_pydict(pydict, self._physical_schema)
arrays = []
fields = []
projected_by_name = {field.name: field for field in self._projected_fields}
for name in self._read_field_names:
if name in projected_by_name:
field = projected_by_name[name]
pa_type = PyarrowFieldParser.from_paimon_type(field.type)
arrays.append(pa.array(pydict[name], type=pa_type))
fields.append(pa.field(name, pa_type, nullable=field.type.nullable))
else:
special_field = _special_field(name)
pa_type = (
PyarrowFieldParser.from_paimon_type(special_field.type)
if special_field is not None else pa.null()
)
arrays.append(pa.nulls(row_count, type=pa_type))
fields.append(pa.field(name, pa_type, nullable=not SpecialFields.is_system_field(name)))
return pa.Table.from_arrays(arrays, schema=pa.schema(fields))
def _decode_block(self, block_data: bytes,
row_filter: Optional[List[int]] = None) -> Tuple[List[List], int]:
data_len = len(block_data)
row_count = struct.unpack_from('<i', block_data, data_len - 4)[0]
offset_array_start = data_len - 4 - row_count * 4
projected_indices = []
for pf in self._projected_fields:
for i, af in enumerate(self._all_fields):
if af.name == pf.name:
projected_indices.append(i)
break
columns = [[] for _ in self._projected_fields]
arity = len(self._all_fields)
header_size = (arity + 7) // 8
rows_to_read = row_filter if row_filter is not None else range(row_count)
rows_to_read_count = len(rows_to_read)
proj_set = set(projected_indices)
proj_col_map = {field_idx: col_idx for col_idx, field_idx in enumerate(projected_indices)}
for row_idx in rows_to_read:
row_offset = struct.unpack_from('<i', block_data, offset_array_start + row_idx * 4)[0]
decoder = _RowDecoder(block_data, row_offset)
null_bitmap = block_data[decoder.pos:decoder.pos + header_size]
decoder.pos += header_size
for field_idx in range(arity):
is_null = (null_bitmap[field_idx // 8] & (1 << (field_idx % 8))) != 0
if is_null:
if field_idx in proj_set:
columns[proj_col_map[field_idx]].append(None)
else:
value = _read_field(decoder, self._all_fields[field_idx].type)
if field_idx in proj_set:
columns[proj_col_map[field_idx]].append(value)
return columns, rows_to_read_count
class _RowDecoder:
__slots__ = ('data', 'pos')
def __init__(self, data: bytes, pos: int = 0):
self.data = data
self.pos = pos
def read_boolean(self) -> bool:
v = self.data[self.pos] != 0
self.pos += 1
return v
def read_byte(self) -> int:
v = struct.unpack_from('<b', self.data, self.pos)[0]
self.pos += 1
return v
def read_short(self) -> int:
v = struct.unpack_from('<h', self.data, self.pos)[0]
self.pos += 2
return v
def read_int(self) -> int:
v = struct.unpack_from('<i', self.data, self.pos)[0]
self.pos += 4
return v
def read_long(self) -> int:
v = struct.unpack_from('<q', self.data, self.pos)[0]
self.pos += 8
return v
def read_float(self) -> float:
v = struct.unpack_from('<f', self.data, self.pos)[0]
self.pos += 4
return v
def read_double(self) -> float:
v = struct.unpack_from('<d', self.data, self.pos)[0]
self.pos += 8
return v
def read_var_int(self) -> int:
result = 0
shift = 0
while True:
b = self.data[self.pos]
self.pos += 1
result |= (b & 0x7F) << shift
if (b & 0x80) == 0:
return result
shift += 7
def read_string(self) -> str:
length = self.read_var_int()
s = self.data[self.pos:self.pos + length].decode('utf-8')
self.pos += length
return s
def read_bytes(self) -> bytes:
length = self.read_var_int()
b = self.data[self.pos:self.pos + length]
self.pos += length
return bytes(b)
def _decode_var_int(data: bytes, offset: int) -> tuple:
result = 0
shift = 0
pos = offset
while True:
b = data[pos]
pos += 1
result |= (b & 0x7F) << shift
if (b & 0x80) == 0:
return result, pos - offset
shift += 7
def _read_field(decoder: _RowDecoder, data_type) -> Any:
if isinstance(data_type, AtomicType):
type_name = data_type.type.upper()
if type_name == 'BOOLEAN':
return decoder.read_boolean()
elif type_name == 'TINYINT':
return decoder.read_byte()
elif type_name == 'SMALLINT':
return decoder.read_short()
elif type_name in ('INT', 'INTEGER', 'DATE', 'TIME'):
return decoder.read_int()
elif type_name.startswith('TIME') and not type_name.startswith('TIMESTAMP'):
return decoder.read_int()
elif type_name == 'BIGINT':
return decoder.read_long()
elif type_name == 'FLOAT':
return decoder.read_float()
elif type_name == 'DOUBLE':
return decoder.read_double()
elif type_name == 'STRING' or type_name.startswith('CHAR') or type_name.startswith('VARCHAR'):
return decoder.read_string()
elif type_name == 'BYTES' or type_name.startswith('BINARY') or type_name.startswith('VARBINARY'):
return decoder.read_bytes()
elif type_name == 'BLOB':
return decoder.read_bytes()
elif type_name.startswith('DECIMAL'):
precision, scale = _parse_decimal_params(type_name)
if precision <= 18:
unscaled = decoder.read_long()
else:
raw = decoder.read_bytes()
unscaled = int.from_bytes(raw, byteorder='big', signed=True)
# Rescale under a context wide enough for the column: the default 28-digit
# precision would round a DECIMAL(p) value with more than 28 significant
# digits, silently corrupting it. Mirrors pypaimon/data/decimal.py.
with localcontext() as ctx:
ctx.prec = max(precision + abs(scale), 38)
return Decimal(unscaled).scaleb(-scale)
elif type_name.startswith('TIMESTAMP'):
precision = _parse_timestamp_precision(type_name)
millis = decoder.read_long()
# The value is placed into the Arrow time unit from_paimon_type maps the
# precision to (0 -> s, 1-3 -> ms, 4-6 -> us, 7-9 -> ns), so it must be
# returned in that unit. nano_of_milli is only on the wire for precision > 3.
if precision == 0:
return millis // 1000
if precision <= 3:
return millis
nano_of_milli = decoder.read_var_int()
if precision <= 6:
return millis * 1000 + nano_of_milli // 1000
return millis * 1_000_000 + nano_of_milli
elif type_name == 'VARIANT':
value_bytes = decoder.read_bytes()
metadata_bytes = decoder.read_bytes()
return {'value': value_bytes, 'metadata': metadata_bytes}
else:
raise ValueError(f"Unsupported atomic type: {type_name}")
elif isinstance(data_type, ArrayType):
return _read_array(decoder, data_type.element)
elif isinstance(data_type, VectorType):
return _read_vector(decoder, data_type.element)
elif isinstance(data_type, MapType):
keys = _read_array_elements(decoder, data_type.key)
values = _read_array_elements(decoder, data_type.value)
return list(zip(keys, values))
elif isinstance(data_type, MultisetType):
keys = _read_array_elements(decoder, data_type.element)
counts = _read_array_elements(decoder, AtomicType("INT"))
return list(zip(keys, counts))
elif isinstance(data_type, RowType):
return _read_nested_row(decoder, data_type)
else:
raise ValueError(f"Unsupported data type: {data_type}")
def _read_array(decoder: _RowDecoder, element_type) -> list:
return _read_array_elements(decoder, element_type)
def _read_array_elements(decoder: _RowDecoder, element_type) -> list:
size = decoder.read_var_int()
null_bitmap_bytes = (size + 7) // 8
null_bitmap = decoder.data[decoder.pos:decoder.pos + null_bitmap_bytes]
decoder.pos += null_bitmap_bytes
elements = []
for i in range(size):
is_null = (null_bitmap[i // 8] & (1 << (i % 8))) != 0
if is_null:
elements.append(None)
else:
elements.append(_read_field(decoder, element_type))
return elements
def _read_vector(decoder: _RowDecoder, element_type) -> list:
size = decoder.read_var_int()
elements = []
for _ in range(size):
elements.append(_read_field(decoder, element_type))
return elements
def _read_nested_row(decoder: _RowDecoder, row_type: RowType) -> dict:
fields = row_type.fields
arity = len(fields)
header_size = (arity + 7) // 8
null_bitmap = decoder.data[decoder.pos:decoder.pos + header_size]
decoder.pos += header_size
result = {}
for i, field in enumerate(fields):
is_null = (null_bitmap[i // 8] & (1 << (i % 8))) != 0
if is_null:
result[field.name] = None
else:
result[field.name] = _read_field(decoder, field.type)
return result
def _parse_decimal_params(type_name: str) -> tuple:
import re
match = re.fullmatch(r'DECIMAL\((\d+),\s*(\d+)\)', type_name)
if match:
return int(match.group(1)), int(match.group(2))
match = re.fullmatch(r'DECIMAL\((\d+)\)', type_name)
if match:
return int(match.group(1)), 0
return 10, 0
def _parse_timestamp_precision(type_name: str) -> int:
import re
match = re.search(r'\((\d+)\)', type_name)
if match:
return int(match.group(1))
return 6