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

364 lines
15 KiB
Python

import logging
import math
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple
from ray.data._internal.util import _check_import
from ray.data.block import Block, BlockAccessor, BlockMetadata
from ray.data.datasource.datasource import Datasource, ReadTask
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
from ray.data.context import DataContext
logger = logging.getLogger(__name__)
def _is_filter_string_safe(filter_str: str) -> bool:
in_string = False
escape_next = False
for c in filter_str:
if in_string:
# If we're inside a string, check if we're closing it.
if c == "'" and not escape_next:
in_string = False
escape_next = (c == "\\") and not escape_next
else:
# If we're not in a string, entering one if we see a single quote
if c == "'":
in_string = True
escape_next = False
# Disallow semicolon if we're not in a string
elif c == ";":
return False
else:
escape_next = False
# If we end inside a string, it's suspicious, but let's allow
# it to be further validated by the DB. Just return True here.
return True
@DeveloperAPI
class ClickHouseDatasource(Datasource):
"""
A Ray datasource for reading from ClickHouse.
Args:
table: Fully qualified table or view identifier (e.g.,
"default.table_name").
dsn: A string in DSN (Data Source Name) HTTP format (e.g.,
"clickhouse+http://username:password@host:8124/default").
For more information, see `ClickHouse Connection String doc
<https://clickhouse.com/docs/en/integrations/sql-clients/cli#connection_string>`_.
columns: Optional List of columns to select from the data source.
If no columns are specified, all columns will be selected by default.
filter: Optional SQL filter string that will be used in the
WHERE statement (e.g., "label = 2 AND text IS NOT NULL").
The filter must be valid for use in a ClickHouse SQL WHERE clause.
Note: Parallel reads are not currently supported when a filter is set.
Specifying a filter forces the parallelism to 1 to ensure deterministic
and consistent results. For more information, see
`ClickHouse SQL WHERE Clause doc
<https://clickhouse.com/docs/en/sql-reference/statements/select/where>`_.
order_by: Optional Tuple containing a list of columns to order by
and a boolean indicating the order. Note: order_by is required to
support parallelism.
client_settings: Optional ClickHouse server settings to be used with the
session/every request. For more information, see
`ClickHouse Client Settings doc
<https://clickhouse.com/docs/en/integrations/python#settings-argument>`_.
client_kwargs: Optional Additional keyword arguments to pass to the
ClickHouse client. For more information,
see `ClickHouse Core Settings doc
<https://clickhouse.com/docs/en/integrations/python#additional-options>`_.
"""
NUM_SAMPLE_ROWS = 100
MIN_ROWS_PER_READ_TASK = 50
_BASE_QUERY = "SELECT {select_clause} FROM {table}"
_EXPLAIN_FILTERS_QUERY = "EXPLAIN SELECT 1 FROM {table} WHERE {filter_clause}"
_SIZE_ESTIMATE_QUERY = "SELECT SUM(byteSize(*)) AS estimate FROM ({query})"
_COUNT_ESTIMATE_QUERY = "SELECT COUNT(*) AS estimate FROM ({query})"
_SAMPLE_BLOCK_QUERY = "{query} LIMIT {limit_row_count}"
_FIRST_BLOCK_QUERY = """
{query}
FETCH FIRST {fetch_row_count} {fetch_row_or_rows} ONLY
"""
_NEXT_BLOCK_QUERY = """
{query}
OFFSET {offset_row_count} {offset_row_or_rows}
FETCH NEXT {fetch_row_count} {fetch_row_or_rows} ONLY
"""
def __init__(
self,
table: str,
dsn: str,
columns: Optional[List[str]] = None,
filter: Optional[str] = None,
order_by: Optional[Tuple[List[str], bool]] = None,
client_settings: Optional[Dict[str, Any]] = None,
client_kwargs: Optional[Dict[str, Any]] = None,
):
self._table = table
self._dsn = dsn
self._columns = columns
self._filter = filter
self._order_by = order_by
self._client_settings = client_settings or {}
self._client_kwargs = client_kwargs or {}
self._query = self._generate_query()
def _init_client(self):
_check_import(self, module="clickhouse_connect", package="clickhouse-connect")
import clickhouse_connect
return clickhouse_connect.get_client(
dsn=self._dsn,
settings=self._client_settings or {},
**self._client_kwargs or {},
)
def _validate_filter(self):
if not self._filter:
return
# Minimal lexical check (regex or manual approach for semicolons, etc.).
if not _is_filter_string_safe(self._filter):
raise ValueError(
f"Invalid characters outside of "
f"string literals in filter: {self._filter}"
)
# Test "EXPLAIN" query to confirm parse-ability and catch expression errors.
client = self._init_client()
try:
test_query = self._EXPLAIN_FILTERS_QUERY.format(
table=self._table,
filter_clause=self._filter,
)
client.query(test_query)
except Exception as e:
raise ValueError(
f"Invalid filter expression: {self._filter}. Error: {e}",
)
finally:
client.close()
def _generate_query(self) -> str:
query = self._BASE_QUERY.format(
select_clause=", ".join(self._columns) if self._columns else "*",
table=self._table,
)
if self._filter:
self._validate_filter()
query += f" WHERE {self._filter}"
if self._order_by:
columns, desc = self._order_by
direction = " DESC" if desc else ""
if len(columns) == 1:
query += f" ORDER BY {columns[0]}{direction}"
elif len(columns) > 1:
columns_clause = ", ".join(columns)
query += f" ORDER BY ({columns_clause}){direction}"
return query
def _build_block_query(self, limit_row_count: int, offset_row_count: int) -> str:
if offset_row_count == 0:
# The first block query is optimized to use FETCH FIRST clause
# with an OFFSET specified.
return self._FIRST_BLOCK_QUERY.format(
query=self._query,
fetch_row_count=limit_row_count,
fetch_row_or_rows="ROWS" if limit_row_count > 1 else "ROW",
)
# Subsequent block queries use OFFSET and FETCH NEXT clauses to read the
# next block of data.
return self._NEXT_BLOCK_QUERY.format(
query=self._query,
offset_row_count=offset_row_count,
offset_row_or_rows="ROWS" if offset_row_count > 1 else "ROW",
fetch_row_count=limit_row_count,
fetch_row_or_rows="ROWS" if limit_row_count > 1 else "ROW",
)
def _create_read_fn(
self,
query: str,
) -> Callable[[], Iterable[Block]]:
def read_fn() -> Iterable[Block]:
return [self._execute_block_query(query)]
return read_fn
def _get_sampled_estimates(self):
if self._order_by is not None:
# If the query is ordered, we can use a FETCH clause to get a sample.
# This reduces the CPU overhead on ClickHouse and speeds up the
# estimation query.
query = self._FIRST_BLOCK_QUERY.format(
query=self._query,
fetch_row_count=self.NUM_SAMPLE_ROWS,
fetch_row_or_rows="ROWS" if self.NUM_SAMPLE_ROWS > 1 else "ROW",
)
else:
# If the query is not ordered, we need to use a LIMIT clause to
# get a sample.
query = self._SAMPLE_BLOCK_QUERY.format(
query=self._query,
limit_row_count=self.NUM_SAMPLE_ROWS,
)
sample_block_accessor = BlockAccessor.for_block(
self._execute_block_query(query)
)
estimated_size_bytes_per_row = math.ceil(
sample_block_accessor.size_bytes() / sample_block_accessor.num_rows()
)
sample_block_schema = sample_block_accessor.schema()
return estimated_size_bytes_per_row, sample_block_schema
def _get_estimate_count(self) -> Optional[int]:
return self._execute_estimate_query(self._COUNT_ESTIMATE_QUERY)
def _get_estimate_size(self) -> Optional[int]:
return self._execute_estimate_query(self._SIZE_ESTIMATE_QUERY)
def _execute_estimate_query(self, estimate_query: str) -> Optional[int]:
client = self._init_client()
try:
# Estimate queries wrap around the primary query, self._query.
# This allows us to use self._query as a sub-query to efficiently
# and accurately estimate the size or count of the result set.
query = estimate_query.format(query=self._query)
result = client.query(query)
if result and len(result.result_rows) > 0:
estimate = result.result_rows[0][0]
return int(estimate) if estimate is not None else None
except Exception as e:
logger.warning(f"Failed to execute estimate query: {e}")
finally:
client.close()
return None
def _execute_block_query(self, query: str) -> Block:
import pyarrow as pa
client = self._init_client()
try:
with client.query_arrow_stream(query) as stream:
record_batches = list(stream) # Collect all record batches
return pa.Table.from_batches(record_batches)
except Exception as e:
raise RuntimeError(f"Failed to execute block query: {e}")
finally:
client.close()
def estimate_inmemory_data_size(self) -> Optional[int]:
"""
Estimate the in-memory data size for the query.
Returns:
Estimated in-memory data size in bytes, or
None if the estimation cannot be performed.
"""
return self._get_estimate_size()
def get_read_tasks(
self,
parallelism: int,
per_task_row_limit: Optional[int] = None,
data_context: Optional["DataContext"] = None,
) -> List[ReadTask]:
"""
Create read tasks for the ClickHouse query.
Args:
parallelism: The desired number of partitions to read the data into.
- If ``order_by`` is not set, parallelism will be forced to 1.
- If ``filter`` is set, parallelism will also be forced to 1
to ensure deterministic results.
per_task_row_limit: Maximum number of rows allowed in each emitted
task. Blocks larger than this limit will be sliced before
being yielded downstream.
data_context: The data context to use to get read tasks. Not used by this
datasource.
Returns:
A list of read tasks to be executed.
"""
num_rows_total = self._get_estimate_count()
if num_rows_total == 0 or num_rows_total is None:
return []
parallelism = min(
parallelism, math.ceil(num_rows_total / self.MIN_ROWS_PER_READ_TASK)
)
# To ensure consistent order of query results, self._order_by
# must be specified and self.filter must not be specified
# in order to support parallelism.
if self._filter is not None and parallelism > 1:
logger.warning(
"ClickHouse datasource does not currently support parallel reads "
"when a filter is set; falling back to parallelism of 1."
)
# When filter is specified and parallelism is greater than 1,
# we need to reduce parallelism to 1 to ensure consistent results.
parallelism = 1
# To ensure consistent order of query results, self._order_by
# must be specified in order to support parallelism.
if self._order_by is None and parallelism > 1:
logger.warning(
"ClickHouse datasource requires dataset to be explicitly ordered "
"to support parallelism; falling back to parallelism of 1."
)
# When order_by is not specified and parallelism is greater than 1,
# we need to reduce parallelism to 1 to ensure consistent results.
parallelism = 1
# By reducing parallelism to 1 when either of the conditions above are met,
# we ensure the downstream process is treated exactly as a non-parallelized
# (single block) process would be, thus ensuring output consistency.
num_rows_per_block = num_rows_total // parallelism
num_blocks_with_extra_row = num_rows_total % parallelism
(
estimated_size_bytes_per_row,
sample_block_schema,
) = self._get_sampled_estimates()
def _get_read_task(
block_rows: int, offset_rows: int, parallelized: bool
) -> ReadTask:
if parallelized:
# When parallelized, we need to build a block query with OFFSET
# and FETCH clauses.
query = self._build_block_query(block_rows, offset_rows)
else:
# When not parallelized, we can use the original query without
# OFFSET and FETCH clauses.
query = self._query
return ReadTask(
self._create_read_fn(query),
BlockMetadata(
num_rows=block_rows,
size_bytes=estimated_size_bytes_per_row * block_rows,
input_files=None,
exec_stats=None,
),
schema=sample_block_schema,
per_task_row_limit=per_task_row_limit,
)
if parallelism == 1:
# When parallelism is 1, we can read the entire dataset in a single task.
# We then optimize this scenario by using self._query directly without
# unnecessary OFFSET and FETCH clauses.
return [_get_read_task(num_rows_total, 0, False)]
# Otherwise we need to split the dataset into multiple tasks.
# Each task will include OFFSET and FETCH clauses to efficiently
# read a subset of the dataset.
read_tasks = []
offset = 0
for i in range(parallelism):
this_block_size = num_rows_per_block
if i < num_blocks_with_extra_row:
this_block_size += 1
read_tasks.append(_get_read_task(this_block_size, offset, True))
offset += this_block_size
return read_tasks