Files
2026-07-13 13:17:40 +08:00

515 lines
19 KiB
Python

"""
Module to read an iceberg table into a Ray Dataset, by using the Ray Datasource API.
"""
import heapq
import itertools
import logging
from functools import partial
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Set, Tuple, Union
import pyarrow as pa
from packaging import version
from ray.data._internal.planner.plan_expression.expression_visitors import _ExprVisitor
from ray.data._internal.util import _check_import
from ray.data.block import Block, BlockMetadata
from ray.data.datasource.datasource import Datasource, ReadTask
from ray.data.expressions import (
AliasExpr,
BinaryExpr,
ColumnExpr,
DownloadExpr,
LiteralExpr,
MonotonicallyIncreasingIdExpr,
Operation,
RandomExpr,
StarExpr,
UDFExpr,
UnaryExpr,
UUIDExpr,
)
from ray.util import log_once
from ray.util.annotations import DeveloperAPI
try:
from pyiceberg.expressions import (
And,
EqualTo,
GreaterThan,
GreaterThanOrEqual,
In,
IsNull,
LessThan,
LessThanOrEqual,
Literal,
Not,
NotEqualTo,
NotIn,
NotNull,
Or,
Reference,
UnboundTerm,
literal,
)
RAY_DATA_OPERATION_TO_ICEBERG = {
Operation.EQ: EqualTo,
Operation.NE: NotEqualTo,
Operation.GT: GreaterThan,
Operation.GE: GreaterThanOrEqual,
Operation.LT: LessThan,
Operation.LE: LessThanOrEqual,
Operation.AND: And,
Operation.OR: Or,
Operation.IN: In,
Operation.NOT_IN: NotIn,
Operation.IS_NULL: IsNull,
Operation.IS_NOT_NULL: NotNull,
Operation.NOT: Not,
}
except ImportError:
log_once("pyiceberg.expressions not found. Please install pyiceberg >= 0.9.0")
if TYPE_CHECKING:
from pyiceberg.catalog import Catalog
from pyiceberg.expressions import BooleanExpression
from pyiceberg.io import FileIO
from pyiceberg.manifest import DataFile
from pyiceberg.schema import Schema
from pyiceberg.table import DataScan, FileScanTask, Table
from pyiceberg.table.metadata import TableMetadata
from ray.data.context import DataContext
logger = logging.getLogger(__name__)
class _IcebergExpressionVisitor(
_ExprVisitor["BooleanExpression | UnboundTerm | Literal"]
):
"""
Visitor that converts Ray Data expressions to PyIceberg expressions.
This enables Ray Data users to write filters using the familiar col() syntax
while leveraging Iceberg's native filtering capabilities.
Example:
>>> from ray.data.expressions import col
>>> ray_expr = (col("date") >= "2024-01-01") & (col("status") == "active")
>>> iceberg_expr = _IcebergExpressionVisitor().visit(ray_expr)
>>> # iceberg_expr can now be used with PyIceberg's filter APIs
"""
def visit_column(self, expr: "ColumnExpr") -> "UnboundTerm":
"""Convert a column reference to an Iceberg reference."""
return Reference(expr.name)
def visit_literal(self, expr: "LiteralExpr") -> "Literal":
"""Convert a literal value to an Iceberg literal."""
return literal(expr.value)
def visit_binary(self, expr: "BinaryExpr") -> "BooleanExpression":
"""Convert a binary operation to an Iceberg expression."""
# Handle IN/NOT_IN specially since they don't visit the right operand
# (the right operand is a list literal that can't be converted)
if expr.op in (Operation.IN, Operation.NOT_IN):
left = self.visit(expr.left)
if not isinstance(expr.right, LiteralExpr):
raise ValueError(
f"{expr.op.name} operation requires right operand to be a literal list, "
f"got {type(expr.right).__name__}"
)
return RAY_DATA_OPERATION_TO_ICEBERG[expr.op](left, expr.right.value)
# For all other operations, visit both operands
left = self.visit(expr.left)
right = self.visit(expr.right)
if expr.op in RAY_DATA_OPERATION_TO_ICEBERG:
return RAY_DATA_OPERATION_TO_ICEBERG[expr.op](left, right)
else:
# Arithmetic operations are not supported in filter expressions
raise ValueError(
f"Unsupported binary operation for Iceberg filters: {expr.op}. "
f"Iceberg filters support: {RAY_DATA_OPERATION_TO_ICEBERG.keys()}. "
f"Arithmetic operations (ADD, SUB, MUL, DIV) cannot be used in filters."
)
def visit_unary(self, expr: "UnaryExpr") -> "BooleanExpression":
"""Convert a unary operation to an Iceberg expression."""
operand = self.visit(expr.operand)
if expr.op in RAY_DATA_OPERATION_TO_ICEBERG:
return RAY_DATA_OPERATION_TO_ICEBERG[expr.op](operand)
else:
raise ValueError(
f"Unsupported unary operation for Iceberg: {expr.op}. "
f"Supported operations: {RAY_DATA_OPERATION_TO_ICEBERG.keys()}"
)
def visit_alias(
self, expr: "AliasExpr"
) -> "BooleanExpression | UnboundTerm | Literal":
"""Convert an aliased expression (just unwrap the alias)."""
return self.visit(expr.expr)
def visit_udf(self, expr: "UDFExpr") -> "BooleanExpression | UnboundTerm | Literal":
"""UDF expressions cannot be converted to Iceberg expressions."""
raise TypeError(
"UDF expressions cannot be converted to Iceberg expressions. "
"Iceberg filters must use simple column comparisons and boolean operations."
)
def visit_download(
self, expr: "DownloadExpr"
) -> "BooleanExpression | UnboundTerm | Literal":
"""Download expressions cannot be converted to Iceberg expressions."""
raise TypeError(
"Download expressions cannot be converted to Iceberg expressions."
)
def visit_star(
self, expr: "StarExpr"
) -> "BooleanExpression | UnboundTerm | Literal":
"""Star expressions cannot be converted to Iceberg expressions."""
raise TypeError(
"Star expressions cannot be converted to Iceberg filter expressions."
)
def visit_monotonically_increasing_id(
self, expr: "MonotonicallyIncreasingIdExpr"
) -> "BooleanExpression | UnboundTerm | Literal":
"""Monotonically increasing ID expressions cannot be converted to Iceberg expressions."""
raise TypeError(
"monotonically_increasing_id expressions cannot be converted to Iceberg filter expressions."
)
def visit_random(
self, expr: "RandomExpr"
) -> "BooleanExpression | UnboundTerm[Any] | Literal[Any]":
"""Random expressions cannot be converted to Iceberg expressions."""
raise TypeError(
"Random expressions cannot be converted to Iceberg filter expressions."
)
def visit_uuid(
self, expr: "UUIDExpr"
) -> "BooleanExpression | UnboundTerm[Any] | Literal[Any]":
"""UUID expressions cannot be converted to Iceberg expressions."""
raise TypeError(
"UUID expressions cannot be converted to Iceberg filter expressions."
)
def _get_read_task(
tasks: Iterable["FileScanTask"],
table_io: "FileIO",
table_metadata: "TableMetadata",
row_filter: "BooleanExpression",
case_sensitive: bool,
limit: Optional[int],
schema: "Schema",
) -> Iterable[Block]:
# Determine the PyIceberg version to handle backward compatibility
import pyiceberg
def _generate_tables() -> Iterable[pa.Table]:
if version.parse(pyiceberg.__version__) >= version.parse("0.9.0"):
# Modern implementation using ArrowScan (PyIceberg 0.9.0+)
from pyiceberg.io.pyarrow import ArrowScan
# Initialize scanner with Iceberg metadata and query parameters
scanner = ArrowScan(
table_metadata=table_metadata,
io=table_io,
row_filter=row_filter,
projected_schema=schema,
case_sensitive=case_sensitive,
limit=limit,
)
# Convert scanned data to Arrow Table format
result_table = scanner.to_table(tasks=tasks)
# Stream results as RecordBatches for memory efficiency
for batch in result_table.to_batches():
yield pa.Table.from_batches([batch])
else:
# Legacy implementation using project_table (PyIceberg <0.9.0)
from pyiceberg.io import pyarrow as pyi_pa_io
# Use the PyIceberg API to read only a single task (specifically, a
# FileScanTask) - note that this is not as simple as reading a single
# parquet file, as there might be delete files, etc. associated, so we
# must use the PyIceberg API for the projection.
table = pyi_pa_io.project_table(
tasks=tasks,
table_metadata=table_metadata,
io=table_io,
row_filter=row_filter,
projected_schema=schema,
case_sensitive=case_sensitive,
limit=limit,
)
yield table
yield from _generate_tables()
@DeveloperAPI
class IcebergDatasource(Datasource):
"""
Iceberg datasource to read Iceberg tables into a Ray Dataset. This module heavily
uses PyIceberg to read iceberg tables. All the routines in this class override
`ray.data.Datasource`.
"""
def __init__(
self,
table_identifier: str,
row_filter: Union[str, "BooleanExpression"] = None,
selected_fields: Tuple[str, ...] = ("*",),
snapshot_id: Optional[int] = None,
scan_kwargs: Optional[Dict[str, Any]] = None,
catalog_kwargs: Optional[Dict[str, Any]] = None,
):
"""
Initialize an IcebergDatasource.
Args:
table_identifier: Fully qualified table identifier (i.e.,
"db_name.table_name")
row_filter: A PyIceberg BooleanExpression to use to filter the data *prior*
to reading
selected_fields: Which columns from the data to read, passed directly to
PyIceberg's load functions
snapshot_id: Optional snapshot ID for the Iceberg table
scan_kwargs: Optional arguments to pass to PyIceberg's Table.scan()
function
catalog_kwargs: Optional arguments to use when setting up the Iceberg
catalog
"""
# Initialize parent class to set up predicate pushdown mixin
super().__init__()
_check_import(self, module="pyiceberg", package="pyiceberg")
from pyiceberg.expressions import AlwaysTrue
self._scan_kwargs = scan_kwargs if scan_kwargs is not None else {}
self._catalog_kwargs = catalog_kwargs if catalog_kwargs is not None else {}
if "name" in self._catalog_kwargs:
self._catalog_name = self._catalog_kwargs.pop("name")
else:
self._catalog_name = "default"
self.table_identifier = table_identifier
self._row_filter = row_filter if row_filter is not None else AlwaysTrue()
# Convert selected_fields to projection_map (identity mapping if specified)
# Note: Empty tuple () means no columns, None/"*" means all columns
if selected_fields is None or selected_fields == ("*",):
self._projection_map = None
else:
self._projection_map = {col: col for col in selected_fields}
if snapshot_id:
self._scan_kwargs["snapshot_id"] = snapshot_id
self._plan_files = None
self._table = None
def _get_catalog(self) -> "Catalog":
from pyiceberg import catalog
return catalog.load_catalog(self._catalog_name, **self._catalog_kwargs)
@property
def table(self) -> "Table":
"""
Return the table reference from the catalog
"""
if self._table is None:
catalog = self._get_catalog()
self._table = catalog.load_table(self.table_identifier)
return self._table
@property
def plan_files(self) -> List["FileScanTask"]:
"""
Return the plan files specified by this query
"""
# Calculate and cache the plan_files if they don't already exist
if self._plan_files is None:
data_scan = self._get_data_scan()
self._plan_files = data_scan.plan_files()
return self._plan_files
def _get_combined_filter(self) -> "BooleanExpression":
"""Get the combined filter including both row_filter and pushed-down predicates."""
combined_filter = self._row_filter
if self._predicate_expr is not None:
# Convert Ray Data expression to PyIceberg expression using internal visitor
visitor = _IcebergExpressionVisitor()
iceberg_filter = visitor.visit(self._predicate_expr)
# Combine with existing row_filter using AND
from pyiceberg.expressions import AlwaysTrue, And
if not isinstance(combined_filter, AlwaysTrue):
combined_filter = And(combined_filter, iceberg_filter)
else:
combined_filter = iceberg_filter
return combined_filter
def _get_data_scan(self) -> "DataScan":
# Get the combined filter
combined_filter = self._get_combined_filter()
# Convert back to tuple for PyIceberg API (None -> ("*",))
data_columns = self._get_data_columns()
selected_fields = ("*",) if data_columns is None else tuple(data_columns)
data_scan = self.table.scan(
row_filter=combined_filter,
selected_fields=selected_fields,
**self._scan_kwargs,
)
return data_scan
def estimate_inmemory_data_size(self) -> Optional[int]:
# Approximate the size by using the plan files - this will not
# incorporate the deletes, but that's a reasonable approximation
# task
return sum(task.file.file_size_in_bytes for task in self.plan_files)
def supports_predicate_pushdown(self) -> bool:
"""Returns True to indicate this datasource supports predicate pushdown."""
return True
def supports_projection_pushdown(self) -> bool:
"""Returns True to indicate this datasource supports projection pushdown."""
return True
@staticmethod
def _distribute_tasks_into_equal_chunks(
plan_files: Iterable["FileScanTask"], n_chunks: int
) -> List[List["FileScanTask"]]:
"""
Implement a greedy knapsack algorithm to distribute the files in the scan
across tasks, based on their file size, as evenly as possible
"""
chunks = [list() for _ in range(n_chunks)]
chunk_sizes = [(0, chunk_id) for chunk_id in range(n_chunks)]
heapq.heapify(chunk_sizes)
# From largest to smallest, add the plan files to the smallest chunk one at a
# time
for plan_file in sorted(
plan_files, key=lambda f: f.file.file_size_in_bytes, reverse=True
):
smallest_chunk = heapq.heappop(chunk_sizes)
chunks[smallest_chunk[1]].append(plan_file)
heapq.heappush(
chunk_sizes,
(
smallest_chunk[0] + plan_file.file.file_size_in_bytes,
smallest_chunk[1],
),
)
return chunks
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
from pyiceberg.io import pyarrow as pyi_pa_io
from pyiceberg.manifest import DataFileContent
# Get the PyIceberg scan
data_scan = self._get_data_scan()
# Get the plan files in this query
plan_files = self.plan_files
# Get the projected schema for this scan, given all the row filters,
# snapshot ID, etc.
projected_schema = data_scan.projection()
# Get the arrow schema, to set in the metadata
pya_schema = pyi_pa_io.schema_to_pyarrow(projected_schema)
# Set the n_chunks to the min of the number of plan files and the actual
# requested n_chunks, so that there are no empty tasks
if parallelism > len(list(plan_files)):
parallelism = len(list(plan_files))
logger.warning(
f"Reducing the parallelism to {parallelism}, as that is the number of files"
)
# Get required properties for reading tasks - table IO, table metadata,
# row filter, case sensitivity,limit and projected schema to pass
# them directly to `_get_read_task` to avoid capture of `self` reference
# within the closure carrying substantial overhead invoking these tasks
#
# See https://github.com/ray-project/ray/issues/49107 for more context
table_io = self.table.io
table_metadata = self.table.metadata
row_filter = self._get_combined_filter()
case_sensitive = self._scan_kwargs.get("case_sensitive", True)
limit = self._scan_kwargs.get("limit")
get_read_task = partial(
_get_read_task,
table_io=table_io,
table_metadata=table_metadata,
row_filter=row_filter,
case_sensitive=case_sensitive,
limit=limit,
schema=projected_schema,
)
read_tasks = []
# Chunk the plan files based on the requested parallelism
for chunk_tasks in IcebergDatasource._distribute_tasks_into_equal_chunks(
plan_files, parallelism
):
unique_deletes: Set[DataFile] = set(
itertools.chain.from_iterable(
[task.delete_files for task in chunk_tasks]
)
)
# Get a rough estimate of the number of deletes by just looking at
# position deletes. Equality deletes are harder to estimate, as they
# can delete multiple rows.
position_delete_count = sum(
delete.record_count
for delete in unique_deletes
if delete.content == DataFileContent.POSITION_DELETES
)
metadata = BlockMetadata(
num_rows=sum(task.file.record_count for task in chunk_tasks)
- position_delete_count,
size_bytes=sum(task.file.file_size_in_bytes for task in chunk_tasks),
input_files=[task.file.file_path for task in chunk_tasks],
exec_stats=None,
)
read_tasks.append(
ReadTask(
read_fn=lambda tasks=chunk_tasks: get_read_task(tasks),
metadata=metadata,
schema=pya_schema,
per_task_row_limit=per_task_row_limit,
)
)
return read_tasks