212 lines
7.6 KiB
Python
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
|