blob: 1c4e25d8510e5541269a5e257eecc6096d7b54c5 [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 io
import re
from dataclasses import dataclass
from typing import BinaryIO, Dict, Iterable, List, Mapping, Optional, Sequence
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.schema.data_types import is_blob_type
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.table.row.blob import Blob, BlobData, BlobDescriptor
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.table.special_fields import SpecialFields
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
_RANGE_PATTERN = re.compile(r"^bytes=(\d*)-(\d*)$")
class NoSuchKey(KeyError):
"""Raised when a blob key does not exist in a multimodal blob store."""
@dataclass(frozen=True)
class PutObjectResult:
key: object
size: Optional[int]
@dataclass(frozen=True)
class ObjectInfo:
key: object
size: Optional[int]
descriptor: Optional[BlobDescriptor]
columns: Dict[str, object]
@dataclass(frozen=True)
class BlobObject:
key: object
descriptor: BlobDescriptor
columns: Dict[str, object]
file_io: object
range_header: Optional[str] = None
@property
def size(self) -> int:
return self.descriptor.length
@property
def content_length(self) -> int:
descriptor = _descriptor_for_range(self.descriptor, self.range_header)
return descriptor.length
def open(self) -> BinaryIO:
descriptor = _descriptor_for_range(self.descriptor, self.range_header)
uri_reader = self.file_io.uri_reader_factory.create(descriptor.uri)
return Blob.from_descriptor(uri_reader, descriptor).new_input_stream()
def read(self) -> bytes:
with self.open() as stream:
return stream.read()
class BlobStore:
"""S3-like object facade over a Paimon multimodal table BLOB column."""
def __init__(
self,
table,
column: Optional[str] = None,
key_column: Optional[str] = None):
self._table = table
self._raw_table = table.raw_table
self.column = column or self._infer_blob_column()
self.key_column = key_column or self._infer_key_column()
self._validate_columns()
def put_object(
self,
key,
body=None,
*,
uri: Optional[str] = None,
offset: int = 0,
length: int = -1,
descriptor: Optional[BlobDescriptor] = None,
columns: Optional[Mapping[str, object]] = None) -> PutObjectResult:
return self.put_objects([
{
"key": key,
"body": body,
"uri": uri,
"offset": offset,
"length": length,
"descriptor": descriptor,
"columns": dict(columns or {}),
}
])[0]
def put_objects(self, objects: Iterable[Mapping[str, object]]) -> List[PutObjectResult]:
rows = []
for obj in objects:
key = _require_key(obj)
row = dict(obj.get("columns") or {})
row[self.key_column] = key
row[self.column] = self._object_to_blob(obj)
rows.append(row)
if not rows:
return []
return self._put_rows(self._deduplicate_rows_last_write_wins(rows))
def update_object_columns(
self,
key,
columns: Mapping[str, object]) -> ObjectInfo:
return self.update_objects_columns([
{"key": key, "columns": columns}
])[0]
def update_objects_columns(
self,
objects: Iterable[Mapping[str, object]]) -> List[ObjectInfo]:
updates = []
keys = []
for obj in objects:
key = _require_key(obj)
columns = dict(obj.get("columns") or {})
if not columns:
raise ValueError("columns must not be empty.")
self._selected_update_columns(columns.keys())
updates.append((key, columns))
keys.append(key)
if not updates:
return []
self._validate_unique_keys(keys)
self._commit_column_updates(updates)
return [
self.head_object(key)
for key in keys
]
def get_object(
self,
key,
*,
range: Optional[str] = None,
columns: Optional[Sequence[str]] = None) -> BlobObject:
info = self.head_object(key, columns=columns)
if info.descriptor is None:
raise NoSuchKey(key)
_descriptor_for_range(info.descriptor, range)
return BlobObject(
key=info.key,
descriptor=info.descriptor,
columns=info.columns,
file_io=self._raw_table.file_io,
range_header=range,
)
def head_object(
self,
key,
*,
columns: Optional[Sequence[str]] = None) -> ObjectInfo:
rows = self._read_rows(
keys=[key],
include_blob=True,
columns=columns,
)
if not rows:
raise NoSuchKey(key)
if len(rows) > 1:
raise ValueError("Multiple rows found for blob key %r." % key)
return self._row_to_info(rows[0])
def list_objects(
self,
*,
prefix: Optional[str] = None,
limit: Optional[int] = None,
columns: Optional[Sequence[str]] = None) -> List[ObjectInfo]:
if limit is not None:
if limit < 0:
raise ValueError("limit must be greater than or equal to 0.")
if limit == 0:
return []
rows = self._read_rows(
include_blob=True,
columns=columns,
)
objects = []
for row in rows:
key = row[self.key_column]
if prefix is not None and not str(key).startswith(prefix):
continue
objects.append(self._row_to_info(row))
if limit is not None and len(objects) >= limit:
break
return objects
def delete_object(self, key) -> None:
self.delete_objects([key])
def delete_objects(self, keys: Sequence[object]) -> None:
if not keys:
return
keys = self._deduplicate_keys(keys)
write_builder = self._raw_table.new_batch_write_builder()
table_update = write_builder.new_update()
table_commit = write_builder.new_commit()
try:
messages = table_update.delete_by_predicate(
self._keys_predicate(keys))
table_commit.commit(messages)
finally:
table_commit.close()
def _put_rows(self, rows: List[Mapping[str, object]]) -> List[PutObjectResult]:
self._commit_upsert_once(rows)
return [
self._put_result_from_row(row)
for row in rows
]
def _deduplicate_rows_last_write_wins(
self,
rows: List[Mapping[str, object]]) -> List[Mapping[str, object]]:
key_to_last_index = {}
for index, row in enumerate(rows):
key_to_last_index[row[self.key_column]] = index
if len(key_to_last_index) == len(rows):
return rows
return [
rows[index]
for index in sorted(key_to_last_index.values())
]
def _deduplicate_keys(self, keys: Sequence[object]) -> List[object]:
unique_keys = []
seen = set()
for key in keys:
if key in seen:
continue
seen.add(key)
unique_keys.append(key)
return unique_keys
def _commit_upsert_once(self, rows: List[Mapping[str, object]]) -> None:
generic_rows = self._rows_to_generic_rows(rows)
write_builder = self._raw_table.new_batch_write_builder()
table_update = write_builder.new_update()
table_commit = write_builder.new_commit()
try:
messages = table_update.upsert_by_key(
generic_rows, [self.key_column])
table_commit.commit(messages)
finally:
table_commit.close()
def _commit_column_updates(self, updates: Sequence[tuple]) -> None:
update_columns = self._column_update_names(updates)
targets = self._read_update_targets(
[key for key, _ in updates],
update_columns,
)
rows = []
row_ids_by_row = []
for key, columns in updates:
target = targets[key]
row = dict(target["columns"])
row.update(columns)
row[self.key_column] = key
rows.append(self._row_to_generic_row(row))
row_ids_by_row.append([target["row_id"]])
write_builder = self._raw_table.new_batch_write_builder()
table_commit = write_builder.new_commit()
try:
messages = TableUpdateByRowId(
self._raw_table,
write_builder.commit_user,
BATCH_COMMIT_IDENTIFIER,
).update_rows_columns(rows, row_ids_by_row, update_columns)
if messages:
table_commit.commit(messages)
finally:
table_commit.close()
def _column_update_names(self, updates: Sequence[tuple]) -> List[str]:
requested = set()
for _, columns in updates:
requested.update(columns)
return [
name for name in self._raw_table.field_names
if name in requested
]
def _read_update_targets(
self,
keys: Sequence[object],
columns: Sequence[str]) -> Dict[object, dict]:
read_builder = self._raw_table.new_read_builder()
projection = [self.key_column, SpecialFields.ROW_ID.name]
projection.extend(columns)
read_builder = read_builder.with_projection(projection)
read_builder = read_builder.with_filter(self._keys_predicate(keys))
scan = read_builder.new_scan()
plan = scan.plan_for_write()
table = read_builder.new_read().to_arrow(plan.splits())
targets = {}
duplicates = []
for row in table.to_pylist():
key = row[self.key_column]
if key in targets:
duplicates.append(key)
continue
targets[key] = {
"row_id": row[SpecialFields.ROW_ID.name],
"columns": {
name: row.get(name)
for name in columns
},
}
missing = [key for key in keys if key not in targets]
if missing:
raise NoSuchKey(missing[0])
if duplicates:
raise ValueError("Multiple rows found for blob keys: %s" % duplicates)
return targets
def _put_result_from_row(self, row: Mapping[str, object]) -> PutObjectResult:
blob = row.get(self.column)
return PutObjectResult(
key=row[self.key_column],
size=_blob_size(blob),
)
def _rows_to_generic_rows(self, rows: List[Mapping[str, object]]) -> List[GenericRow]:
return [
self._row_to_generic_row(row)
for row in rows
]
def _row_to_generic_row(self, row: Mapping[str, object]) -> GenericRow:
return GenericRow(
[row.get(field.name) for field in self._raw_table.fields],
self._raw_table.fields,
)
def _read_rows(
self,
keys: Optional[Sequence[object]] = None,
include_blob: bool = False,
columns: Optional[Sequence[str]] = None) -> List[dict]:
read_table = self._raw_table.copy({
CoreOptions.BLOB_AS_DESCRIPTOR.key(): "true"
}) if include_blob else self._raw_table
read_builder = read_table.new_read_builder()
projection = self._projection(include_blob, columns)
read_builder = read_builder.with_projection(projection)
if keys is not None:
read_builder = read_builder.with_filter(self._keys_predicate(keys))
plan = read_builder.new_scan().plan()
table = read_builder.new_read().to_arrow(plan.splits())
return table.to_pylist()
def _projection(
self,
include_blob: bool,
selected_columns: Optional[Sequence[str]]) -> List[str]:
projection = [self.key_column]
if include_blob:
projection.append(self.column)
projection.extend(self._selected_columns(selected_columns))
return projection
def _row_to_info(self, row: Mapping[str, object]) -> ObjectInfo:
descriptor_bytes = row.get(self.column)
descriptor = None
if descriptor_bytes is not None:
descriptor = BlobDescriptor.deserialize(_bytes_value(descriptor_bytes))
columns = {
name: value
for name, value in row.items()
if name not in (self.key_column, self.column)
}
return ObjectInfo(
key=row[self.key_column],
size=descriptor.length if descriptor is not None else None,
descriptor=descriptor,
columns=columns,
)
def _keys_predicate(self, keys: Sequence[object]):
builder = PredicateBuilder(self._raw_table.fields)
values = list(keys)
if len(values) == 1:
return builder.equal(self.key_column, values[0])
return builder.is_in(self.key_column, values)
def _infer_blob_column(self) -> str:
blob_columns = [
field.name for field in self._raw_table.fields
if _is_blob_field(field)
]
if len(blob_columns) != 1:
raise ValueError(
"Blob column is required when table has %d BLOB columns: %s."
% (len(blob_columns), blob_columns)
)
return blob_columns[0]
def _infer_key_column(self) -> str:
for candidate in ("key", "object_key", "path"):
if candidate in self._raw_table.field_names:
return candidate
raise ValueError(
"key_column is required; no default key/object_key/path column "
"exists in table schema."
)
def _validate_columns(self):
if self.key_column not in self._raw_table.field_names:
raise ValueError("key_column %r is not in table schema." % self.key_column)
if self.column not in self._raw_table.field_names:
raise ValueError("blob column %r is not in table schema." % self.column)
if not _is_blob_field(self._raw_table.field_dict[self.column]):
raise ValueError("Column %r is not a BLOB column." % self.column)
if self.key_column == self.column:
raise ValueError("key_column and blob column must be different.")
def _object_to_blob(self, obj: Mapping[str, object]) -> Blob:
has_body = "body" in obj and obj.get("body") is not None
has_reference = obj.get("descriptor") is not None or obj.get("uri") is not None
if has_body and has_reference:
raise ValueError(
"Blob object spec must use either 'body' or descriptor/uri, not both."
)
if has_reference:
descriptor = _coerce_descriptor(obj)
uri_reader = self._raw_table.file_io.uri_reader_factory.create(descriptor.uri)
return Blob.from_descriptor(uri_reader, descriptor)
if not has_body:
raise ValueError("Blob object spec requires 'body', 'descriptor', or 'uri'.")
body = obj.get("body")
if isinstance(body, Blob):
try:
descriptor = body.to_descriptor()
except RuntimeError:
return body
uri_reader = self._raw_table.file_io.uri_reader_factory.create(descriptor.uri)
return Blob.from_descriptor(uri_reader, descriptor)
return BlobData(_body_to_bytes(body))
def _selected_columns(
self,
columns: Optional[Sequence[str]]) -> List[str]:
if columns is None:
return [
name for name in self._raw_table.field_names
if name not in (self.key_column, self.column)
]
if isinstance(columns, str):
names = [columns]
else:
names = list(columns)
self._validate_unique_selected_columns(names)
for name in names:
if name in (self.key_column, self.column):
raise ValueError(
"columns must not include key or blob column %r." % name
)
if name not in self._raw_table.field_names:
raise ValueError("Column %r is not in table schema." % name)
return names
def _selected_update_columns(self, columns: Sequence[str]) -> List[str]:
names = self._selected_columns(columns)
partition_keys = set(self._raw_table.partition_keys)
for name in names:
if name in partition_keys:
raise ValueError(
"columns must not include partition column %r." % name
)
return names
@staticmethod
def _validate_unique_selected_columns(names: Sequence[str]):
seen = set()
duplicates = set()
for name in names:
if name in seen:
duplicates.add(name)
seen.add(name)
if duplicates:
raise ValueError("Duplicate columns: %s" % sorted(duplicates))
@staticmethod
def _validate_unique_keys(keys: Sequence[object]):
seen = set()
duplicates = set()
for key in keys:
if key in seen:
duplicates.add(key)
seen.add(key)
if duplicates:
raise ValueError("Duplicate blob keys: %s" % sorted(duplicates))
def _require_key(obj: Mapping[str, object]):
if "key" not in obj:
raise ValueError("Blob object spec requires 'key'.")
key = obj["key"]
if key is None:
raise ValueError("Blob object key must not be None.")
return key
def _body_to_bytes(body) -> bytes:
if isinstance(body, bytes):
return body
if isinstance(body, bytearray):
return bytes(body)
if isinstance(body, memoryview):
return body.tobytes()
if isinstance(body, str):
return body.encode("utf-8")
if isinstance(body, io.IOBase) or hasattr(body, "read"):
data = body.read()
if isinstance(data, str):
return data.encode("utf-8")
return bytes(data)
raise ValueError("Unsupported blob body type: %r." % type(body))
def _coerce_descriptor(obj: Mapping[str, object]) -> BlobDescriptor:
descriptor = obj.get("descriptor")
if descriptor is not None:
if isinstance(descriptor, BlobDescriptor):
return descriptor
if isinstance(descriptor, (bytes, bytearray)):
return BlobDescriptor.deserialize(bytes(descriptor))
raise ValueError("descriptor must be BlobDescriptor or serialized bytes.")
uri = obj.get("uri")
if not uri:
raise ValueError("Reference object spec requires 'uri' or 'descriptor'.")
return BlobDescriptor(
uri=str(uri),
offset=int(obj.get("offset", 0)),
length=int(obj.get("length", -1)),
)
def _descriptor_for_range(
descriptor: BlobDescriptor,
range_header: Optional[str]) -> BlobDescriptor:
if range_header is None:
return descriptor
start, length = _parse_range(range_header, descriptor.length)
return BlobDescriptor(descriptor.uri, descriptor.offset + start, length)
def _parse_range(range_header: str, total_length: int):
match = _RANGE_PATTERN.match(range_header)
if match is None:
raise ValueError("Range must use S3 syntax like 'bytes=0-1023'.")
start_text, end_text = match.groups()
if not start_text and not end_text:
raise ValueError("Range must specify a start or end byte.")
if start_text:
start = int(start_text)
if total_length >= 0 and start >= total_length:
raise ValueError("Range start is past the end of the object.")
if end_text:
end = int(end_text)
if end < start:
raise ValueError("Range end must be greater than or equal to start.")
if total_length >= 0:
end = min(end, total_length - 1)
length = end - start + 1
else:
if total_length < 0:
length = -1
else:
length = max(total_length - start, 0)
else:
if total_length < 0:
raise ValueError("Suffix ranges require a known object length.")
suffix_length = int(end_text)
if suffix_length < 0:
raise ValueError("Suffix range length must be non-negative.")
length = min(suffix_length, total_length)
start = total_length - length
return start, length
def _blob_descriptor(blob) -> Optional[BlobDescriptor]:
if blob is None:
return None
try:
return blob.to_descriptor()
except RuntimeError:
return None
def _blob_size(blob) -> Optional[int]:
descriptor = _blob_descriptor(blob)
if descriptor is not None:
return descriptor.length if descriptor.length >= 0 else None
if isinstance(blob, BlobData):
return len(blob.data)
return None
def _bytes_value(value) -> bytes:
if hasattr(value, "as_py"):
value = value.as_py()
if isinstance(value, bytearray):
value = bytes(value)
if not isinstance(value, bytes):
raise ValueError("Expected serialized BlobDescriptor bytes.")
return value
def _is_blob_field(field) -> bool:
return is_blob_type(field.type)