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

212 lines
7.6 KiB
Python

import logging
import math
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, List, Optional
from ray.data.block import Block, BlockMetadata
from ray.data.datasource.datasource import Datasource, ReadTask
Connection = Any # A Python DB API2-compliant `Connection` object.
Cursor = Any # A Python DB API2-compliant `Cursor` object.
if TYPE_CHECKING:
from ray.data.context import DataContext
logger = logging.getLogger(__name__)
def _cursor_to_block(cursor) -> Block:
import pyarrow as pa
rows = cursor.fetchall()
# Each `column_description` is a 7-element sequence. The first element is the column
# name. To learn more, read https://peps.python.org/pep-0249/#description.
columns = [column_description[0] for column_description in cursor.description]
pydict = {column: [row[i] for row in rows] for i, column in enumerate(columns)}
return pa.Table.from_pydict(pydict)
def _check_connection_is_dbapi2_compliant(connection) -> None:
for attr in "close", "commit", "cursor":
if not hasattr(connection, attr):
raise ValueError(
"Your `connection_factory` created a `Connection` object without a "
f"{attr!r} method, but this method is required by the Python DB API2 "
"specification. Check that your database connector is DB API2-"
"compliant. To learn more, read https://peps.python.org/pep-0249/."
)
def _check_cursor_is_dbapi2_compliant(cursor) -> None:
# These aren't all the methods required by the specification, but it's all the ones
# we care about.
for attr in "execute", "executemany", "fetchone", "fetchall", "description":
if not hasattr(cursor, attr):
raise ValueError(
"Your database connector created a `Cursor` object without a "
f"{attr!r} method, but this method is required by the Python DB API2 "
"specification. Check that your database connector is DB API2-"
"compliant. To learn more, read https://peps.python.org/pep-0249/."
)
@contextmanager
def _connect(connection_factory: Callable[[], Connection]) -> Iterator[Cursor]:
connection = connection_factory()
_check_connection_is_dbapi2_compliant(connection)
try:
cursor = connection.cursor()
_check_cursor_is_dbapi2_compliant(cursor)
yield cursor
connection.commit()
except Exception:
# `rollback` is optional since not all databases provide transaction support.
try:
connection.rollback()
except Exception as e:
# Each connector implements its own `NotSupportError` class, so we check
# the exception's name instead of using `isinstance`.
if (
isinstance(e, AttributeError)
or e.__class__.__name__ == "NotSupportedError"
):
pass
raise
finally:
connection.close()
def _execute(cursor: Cursor, sql: str, params: Optional[Any]) -> None:
if params is None:
cursor.execute(sql)
else:
cursor.execute(sql, params)
class SQLDatasource(Datasource):
MIN_ROWS_PER_READ_TASK = 50
def __init__(
self,
sql: str,
connection_factory: Callable[[], Connection],
shard_hash_fn: str,
shard_keys: Optional[List[str]] = None,
sql_params: Optional[Any] = None,
):
self.sql = sql
if shard_keys and len(shard_keys) > 1:
self.shard_keys = f"CONCAT({','.join(shard_keys)})"
elif shard_keys and len(shard_keys) == 1:
self.shard_keys = f"{shard_keys[0]}"
else:
self.shard_keys = None
self.shard_hash_fn = shard_hash_fn
self.connection_factory = connection_factory
self.sql_params = sql_params
def estimate_inmemory_data_size(self) -> Optional[int]:
return None
def supports_sharding(self, parallelism: int) -> bool:
"""Check if database supports sharding with MOD/ABS/CONCAT operations.
Args:
parallelism: The number of shards to split the read into.
Returns:
bool: True if sharding is supported, False otherwise.
"""
if parallelism <= 1 or self.shard_keys is None:
return False
# Test if database supports required operations (MOD, ABS, MD5, CONCAT)
# by executing a sample query
hash_fn = self.shard_hash_fn
query = (
f"SELECT COUNT(1) FROM ({self.sql}) as T"
f" WHERE MOD(ABS({hash_fn}({self.shard_keys})), {parallelism}) = 0"
)
try:
with _connect(self.connection_factory) as cursor:
_execute(cursor, query, self.sql_params)
return True
except Exception as e:
logger.info(f"Database does not support sharding: {str(e)}.")
return False
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
def fallback_read_fn() -> Iterable[Block]:
"""Read all data in a single block when sharding is not supported."""
with _connect(self.connection_factory) as cursor:
_execute(cursor, self.sql, self.sql_params)
return [_cursor_to_block(cursor)]
# Check if sharding is supported by the database first
# If not, fall back to reading all data in a single task without counting rows
if not self.supports_sharding(parallelism):
logger.info(
"Sharding is not supported. "
"Falling back to reading all data in a single task."
)
metadata = BlockMetadata(None, None, None, None)
return [ReadTask(fallback_read_fn, metadata)]
# Only perform the expensive COUNT(*) query if sharding is supported
num_rows_total = self._get_num_rows()
if num_rows_total == 0:
return []
parallelism = min(
parallelism, math.ceil(num_rows_total / self.MIN_ROWS_PER_READ_TASK)
)
num_rows_per_block = num_rows_total // parallelism
num_blocks_with_extra_row = num_rows_total % parallelism
tasks = []
for i in range(parallelism):
num_rows = num_rows_per_block
if i < num_blocks_with_extra_row:
num_rows += 1
read_fn = self._create_parallel_read_fn(i, parallelism)
metadata = BlockMetadata(
num_rows=num_rows,
size_bytes=None,
input_files=None,
exec_stats=None,
)
tasks.append(
ReadTask(read_fn, metadata, per_task_row_limit=per_task_row_limit)
)
return tasks
def _get_num_rows(self) -> int:
with _connect(self.connection_factory) as cursor:
_execute(cursor, f"SELECT COUNT(*) FROM ({self.sql}) as T", self.sql_params)
return cursor.fetchone()[0]
def _create_parallel_read_fn(self, task_id: int, parallelism: int):
hash_fn = self.shard_hash_fn
query = (
f"SELECT * FROM ({self.sql}) as T "
f"WHERE MOD(ABS({hash_fn}({self.shard_keys})), {parallelism}) = {task_id}"
)
def read_fn() -> Iterable[Block]:
with _connect(self.connection_factory) as cursor:
_execute(cursor, query, self.sql_params)
block = _cursor_to_block(cursor)
return [block]
return read_fn