blob: 4b50c86c289be1ab98ef134ad2fef8bc88c15e49 [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.
"""Deserialize the cross-language ``SplitSerializer`` v1 binary into a pypaimon
:class:`DataSplit`.
Mirror of the Java ``DataSplit#serialize`` (VERSION 8) frame wrapped in the
``SplitSerializer`` v1 header, as produced by ``pypaimon_rust``'s
``Split.serialize()``. Extracts the fields the reader needs, plus per-file
min/max keys for PK merge-on-read; key/value stats (planning-only) stay empty.
"""
import struct
from typing import List, Optional
from pypaimon.data.timestamp import Timestamp
from pypaimon.globalindex.indexed_split import IndexedSplit
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.manifest.schema.simple_stats import SimpleStats
from pypaimon.read.split import DataSplit, Split
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.table.row.binary_row import BinaryRow
from pypaimon.table.row.generic_row import GenericRow, GenericRowDeserializer
from pypaimon.table.source.deletion_file import DeletionFile
from pypaimon.utils.range import Range
# Frame magics/versions, mirroring the Rust/Java constants.
_SPLIT_SER_MAGIC = 0x53504C49545F5631 # "SPLIT_V1"
_SPLIT_SER_VERSION = 1
_TYPE_DATA_SPLIT = 1
_TYPE_INDEXED_SPLIT = 3
_INDEXED_SPLIT_MAGIC = -938472394838495695
_INDEXED_SPLIT_VERSION = 1
_SPLIT_MAGIC = -2394839472490812314
_SPLIT_VERSION = 8
_DFM_ARITY = 20
def _f(idx, name, dtype):
return DataField(idx, name, dtype)
# DataFileMeta 20-field layout (order/types mirror DataFileMetaSerializer#toRow).
# Fields 3/4 (min/max key) are decoded for PK tables; 5/6 (stats) stay unread.
_DFM_FIELDS: List[DataField] = [
_f(0, '_FILE_NAME', AtomicType('STRING')),
_f(1, '_FILE_SIZE', AtomicType('BIGINT')),
_f(2, '_ROW_COUNT', AtomicType('BIGINT')),
_f(3, '_MIN_KEY', AtomicType('BYTES')),
_f(4, '_MAX_KEY', AtomicType('BYTES')),
_f(5, '_KEY_STATS', AtomicType('BYTES')),
_f(6, '_VALUE_STATS', AtomicType('BYTES')),
_f(7, '_MIN_SEQUENCE_NUMBER', AtomicType('BIGINT')),
_f(8, '_MAX_SEQUENCE_NUMBER', AtomicType('BIGINT')),
_f(9, '_SCHEMA_ID', AtomicType('BIGINT')),
_f(10, '_LEVEL', AtomicType('INT')),
_f(11, '_EXTRA_FILES', AtomicType('BYTES')), # BinaryArray<string>, decoded below
_f(12, '_CREATION_TIME', AtomicType('BIGINT')), # compact millis; read raw, wrap Timestamp
_f(13, '_DELETE_ROW_COUNT', AtomicType('BIGINT')),
_f(14, '_EMBEDDED_FILE_INDEX', AtomicType('BYTES')),
_f(15, '_FILE_SOURCE', AtomicType('TINYINT')),
_f(16, '_VALUE_STATS_COLS', AtomicType('BYTES')),
_f(17, '_EXTERNAL_PATH', AtomicType('STRING')),
_f(18, '_FIRST_ROW_ID', AtomicType('BIGINT')),
_f(19, '_WRITE_COLS', AtomicType('BYTES')),
]
# Arity is fixed by DataSplit VERSION 8; keep the field list and arity in lockstep.
assert len(_DFM_FIELDS) == _DFM_ARITY
def _decode_str_array(b: Optional[bytes]) -> Optional[List[str]]:
"""Decode a Paimon ``BinaryArray<string>`` (non-null elements)."""
if not b:
return [] if b == b'' else None
n = struct.unpack_from('<i', b, 0)[0]
header = 4 + ((n + 31) // 32) * 4 # count + null bitset
out = []
for k in range(n):
eo = header + k * 8
if b[eo + 7] & 0x80: # inline: value in first 7 bytes, len in low 7 bits of byte 7
length = b[eo + 7] & 0x7F
out.append(b[eo:eo + length].decode('utf-8'))
else: # pointer: (var_off << 32) | len
slot = struct.unpack_from('<q', b, eo)[0]
var_off = (slot >> 32) & 0xFFFFFFFF
length = slot & 0xFFFFFFFF
out.append(b[var_off:var_off + length].decode('utf-8'))
return out
def _decode_modified_utf8(raw: bytes) -> str:
"""Decode Java writeUTF modified UTF-8 (NUL as C0 80, 3-byte for >= U+0800;
supplementary chars as a UTF-16 surrogate pair, recombined below)."""
units = []
i, n = 0, len(raw)
while i < n:
b = raw[i]
if b < 0x80:
units.append(b)
i += 1
elif b & 0xE0 == 0xC0:
units.append(((b & 0x1F) << 6) | (raw[i + 1] & 0x3F))
i += 2
else: # 0xE0: 3-byte
units.append(((b & 0x0F) << 12) | ((raw[i + 1] & 0x3F) << 6) | (raw[i + 2] & 0x3F))
i += 3
out = []
j, m = 0, len(units)
while j < m:
c = units[j]
if 0xD800 <= c <= 0xDBFF and j + 1 < m and 0xDC00 <= units[j + 1] <= 0xDFFF:
out.append(0x10000 + ((c - 0xD800) << 10) + (units[j + 1] - 0xDC00))
j += 2
else:
out.append(c)
j += 1
return ''.join(chr(c) for c in out)
class _Reader:
def __init__(self, data: bytes):
self.d = data
self.p = 0
def i32(self) -> int:
v = struct.unpack_from('>i', self.d, self.p)[0]
self.p += 4
return v
def i64(self) -> int:
v = struct.unpack_from('>q', self.d, self.p)[0]
self.p += 8
return v
def f32(self) -> float:
v = struct.unpack_from('>f', self.d, self.p)[0]
self.p += 4
return v
def u8(self) -> int:
v = self.d[self.p]
self.p += 1
return v
def take(self, n: int) -> bytes:
v = self.d[self.p:self.p + n]
self.p += n
return v
def java_utf(self) -> str:
n = struct.unpack_from('>H', self.d, self.p)[0]
self.p += 2
return _decode_modified_utf8(self.take(n))
def deserialize_split_v1(data: bytes, partition_fields: List[DataField],
key_fields: Optional[List[DataField]] = None) -> Split:
"""Rebuild a pypaimon ``DataSplit`` (or ``IndexedSplit``) from ``Split.serialize()`` bytes.
``key_fields`` (trimmed primary keys) decode per-file min/max keys for PK
merge-on-read; None for append tables.
"""
r = _Reader(data)
magic = r.i64()
if magic != _SPLIT_SER_MAGIC:
raise ValueError("bad SplitSerializer magic %d" % magic)
version = r.i32()
if version != _SPLIT_SER_VERSION:
raise ValueError(
"unsupported SplitSerializer version %d (expected %d)" % (version, _SPLIT_SER_VERSION))
type_id = r.i32()
if type_id == _TYPE_DATA_SPLIT:
return _read_datasplit_body(r, partition_fields, key_fields)
if type_id == _TYPE_INDEXED_SPLIT:
imagic = r.i64()
if imagic != _INDEXED_SPLIT_MAGIC:
raise ValueError("bad IndexedSplit magic %d" % imagic)
iversion = r.i32()
if iversion != _INDEXED_SPLIT_VERSION:
raise ValueError(
"unsupported IndexedSplit version %d (expected %d)" % (iversion, _INDEXED_SPLIT_VERSION))
data_split = _read_datasplit_body(r, partition_fields, key_fields)
# row_ranges select which global row ids to read -- must be preserved,
# else the reader scans the whole file instead of the ANN/row-id result.
row_ranges = [Range(r.i64(), r.i64()) for _ in range(r.i32())]
return IndexedSplit(data_split, row_ranges, _read_scores(r))
raise ValueError("unsupported split type id %d" % type_id)
def _read_scores(r: '_Reader') -> Optional[List[float]]:
if r.u8() == 0:
return None
return [r.f32() for _ in range(r.i32())]
def _read_datasplit_body(r: _Reader, partition_fields: List[DataField],
key_fields: Optional[List[DataField]] = None) -> DataSplit:
if r.i64() != _SPLIT_MAGIC:
raise ValueError("bad DataSplit magic")
version = r.i32()
if version != _SPLIT_VERSION:
raise ValueError(
"unsupported DataSplit version %d (expected %d)" % (version, _SPLIT_VERSION))
snapshot_id = r.i64() # scanned snapshot; row-id conflict detection needs it
partition = GenericRowDeserializer.from_bytes(r.take(r.i32()), partition_fields)
bucket = r.i32()
bucket_path = r.java_utf()
if r.u8() == 1:
r.i32() # total_buckets
if r.i32() != 0: # deprecated beforeFiles; Java rejects non-empty
raise ValueError("cannot deserialize a split with before files")
if r.u8() != 0: # beforeDeletionFiles must be null
raise ValueError("cannot deserialize a split with before deletion files")
file_count = r.i32()
files = [_datafilemeta_from_row(r.take(r.i32()), bucket_path, key_fields)
for _ in range(file_count)]
data_deletion_files = _read_deletion_list(r)
r.u8() # isStreaming
raw_convertible = r.u8() != 0
return DataSplit(
files=files,
partition=partition,
bucket=bucket,
raw_convertible=raw_convertible,
data_deletion_files=data_deletion_files,
snapshot_id=snapshot_id,
)
def _decode_key(b: Optional[bytes], key_fields: Optional[List[DataField]]) -> GenericRow:
"""Decode a min/max key (serialized BinaryRow); empty for append tables."""
if not key_fields or not b:
return GenericRow([], [])
return GenericRowDeserializer.from_bytes(b, key_fields)
def _datafilemeta_from_row(row_bytes: bytes, bucket_path: str,
key_fields: Optional[List[DataField]] = None) -> DataFileMeta:
row = BinaryRow(struct.pack('>i', _DFM_ARITY) + row_bytes, _DFM_FIELDS)
g = row.get_field
file_name = g(0)
external_path = g(17)
ct = g(12)
ct = Timestamp(int(ct)) if ct is not None else None
# min/max keys drive PK merge-on-read; stats unused, left empty.
meta = DataFileMeta(
file_name=file_name,
file_size=g(1),
row_count=g(2),
min_key=_decode_key(g(3), key_fields),
max_key=_decode_key(g(4), key_fields),
key_stats=SimpleStats.empty_stats(),
value_stats=SimpleStats.empty_stats(),
min_sequence_number=g(7),
max_sequence_number=g(8),
schema_id=g(9),
level=g(10),
extra_files=_decode_str_array(g(11)) or [],
creation_time=ct,
delete_row_count=g(13),
embedded_index=g(14),
file_source=g(15),
value_stats_cols=_decode_str_array(g(16)),
external_path=external_path,
first_row_id=g(18),
write_cols=_decode_str_array(g(19)),
)
meta.file_path = external_path if external_path else "%s/%s" % (bucket_path.rstrip('/'), file_name)
return meta
def _read_deletion_list(r: _Reader) -> Optional[List[Optional[DeletionFile]]]:
if r.u8() == 0:
return None
result: List[Optional[DeletionFile]] = []
for _ in range(r.i32()):
if r.u8() == 0:
result.append(None)
continue
path = r.java_utf()
offset, length, cardinality = r.i64(), r.i64(), r.i64()
result.append(DeletionFile(
dv_index_path=path,
offset=offset,
length=length,
cardinality=None if cardinality == -1 else cardinality,
))
return result