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

436 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
import re
from dataclasses import dataclass
from enum import IntEnum
from typing import (
Any,
Dict,
Iterable,
Optional,
)
import pyarrow
import pyarrow as pa
import pyarrow.types as pat
from ray.data._internal.arrow_ops.transform_pyarrow import (
reorder_columns_by_schema,
)
from ray.data._internal.execution.interfaces import TaskContext
from ray.data._internal.util import _check_import
from ray.data.block import Block, BlockAccessor
from ray.data.datasource.datasink import Datasink, WriteReturnType
from ray.util.annotations import DeveloperAPI, PublicAPI
logger = logging.getLogger(__name__)
DEFAULT_DECIMAL_PRECISION = 38
DEFAULT_DECIMAL_SCALE = 10
def _pick_best_arrow_field_for_order_by(schema: pyarrow.Schema) -> str:
if len(schema) == 0:
return "tuple()"
# Prefer a timestamp column if available
for f in schema:
if pat.is_timestamp(f.type):
return f.name
# Next prefer a non-string column
for f in schema:
if not (pat.is_string(f.type) or pat.is_large_string(f.type)):
return f.name
# Otherwise pick the first column
return schema[0].name
def _arrow_to_clickhouse_type(field: pyarrow.Field) -> str:
"""Convert a PyArrow field to an appropriate ClickHouse column type."""
t = field.type
if pat.is_decimal(t):
precision = t.precision or DEFAULT_DECIMAL_PRECISION
scale = t.scale or DEFAULT_DECIMAL_SCALE
return f"Decimal({precision}, {scale})"
if pat.is_boolean(t):
return "UInt8"
if pat.is_int8(t):
return "Int8"
if pat.is_int16(t):
return "Int16"
if pat.is_int32(t):
return "Int32"
if pat.is_int64(t):
return "Int64"
if pat.is_uint8(t):
return "UInt8"
if pat.is_uint16(t):
return "UInt16"
if pat.is_uint32(t):
return "UInt32"
if pat.is_uint64(t):
return "UInt64"
if pat.is_float16(t):
return "Float32"
if pat.is_float32(t):
return "Float32"
if pat.is_float64(t):
return "Float64"
if pat.is_timestamp(t):
return "DateTime64(3)"
return "String"
@dataclass
class ClickHouseTableSettings:
"""
Additional table creation instructions for ClickHouse.
Attributes:
engine: The engine definition for the created table. Defaults
to "MergeTree()".
order_by: The ORDER BY clause for the table.
partition_by: The PARTITION BY clause for the table.
primary_key: The PRIMARY KEY clause for the table.
settings: Additional SETTINGS clause for the table
(comma-separated or any valid string).
"""
engine: str = "MergeTree()"
order_by: Optional[str] = None
partition_by: Optional[str] = None
primary_key: Optional[str] = None
settings: Optional[str] = None
@PublicAPI(stability="alpha")
class SinkMode(IntEnum):
"""
Enum of possible modes for sinking data
Attributes:
CREATE: Create a new table; fail if that table already exists.
APPEND: Use an existing table if present, otherwise create one; then append data.
OVERWRITE: Drop the table if it already exists, then re-create it and write.
"""
# Create a new table and fail if that table already exists.
CREATE = 1
# Append data to an existing table, or create one if it does not exist.
APPEND = 2
# Drop the table if it already exists, then re-create it and write.
OVERWRITE = 3
@DeveloperAPI
class ClickHouseDatasink(Datasink):
"""ClickHouse Ray Datasink.
A Ray Datasink for writing data into ClickHouse, with support for distributed
writes and mode-based table management (create, append, or overwrite).
Args:
table: Fully qualified table identifier (e.g., "default.my_table").
dsn: A string in DSN (Data Source Name) HTTP format
(e.g., "clickhouse+http://username:password@host:8123/default").
For more information, see `ClickHouse Connection String doc
<https://clickhouse.com/docs/en/integrations/sql-clients/cli#connection_string>`_.
mode: One of SinkMode.CREATE, SinkMode.APPEND,
or SinkMode.OVERWRITE.
- **CREATE**: Create a new table; fail if that table already exists.
Requires a user-supplied schema if the table doesnt already exist.
- **APPEND**: Use an existing table if present, otherwise create one.
If the table does not exist, the user must supply a schema. Data
is then appended to the table.
- **OVERWRITE**: Drop the table if it exists, then re-create it.
**Always requires** a user-supplied schema to define the new table.
schema: An optional PyArrow schema object that, if provided, will
override any inferred schema for table creation.
- If you are creating a new table (CREATE or APPEND when the table
doesnt exist) or overwriting an existing table, you **must**
provide a schema.
- If youre appending to an already-existing table, the schema is
not strictly required unless you want to cast data or enforce
column types. If omitted, the existing table definition is used.
client_settings: Optional ClickHouse server settings to be used with the
session/every request.
client_kwargs: Additional keyword arguments to pass to the
ClickHouse client.
table_settings: An optional dataclass with additional table creation
instructions (e.g., engine, order_by, partition_by, primary_key, settings).
max_insert_block_rows: If you have extremely large blocks, specifying
a limit here will chunk the insert into multiple smaller insert calls.
Defaults to None (no chunking).
"""
NAME = "ClickHouse"
_CREATE_TABLE_TEMPLATE = """
CREATE TABLE IF NOT EXISTS {table_name} (
{columns}
)
ENGINE = {engine}
ORDER BY {order_by}
{additional_props}
"""
_DROP_TABLE_TEMPLATE = """DROP TABLE IF EXISTS {table_name}"""
_CHECK_TABLE_EXISTS_TEMPLATE = """EXISTS {table_name}"""
_SHOW_CREATE_TABLE_TEMPLATE = """SHOW CREATE TABLE {table_name}"""
def __init__(
self,
table: str,
dsn: str,
mode: SinkMode = SinkMode.CREATE,
schema: Optional[pyarrow.Schema] = None,
client_settings: Optional[Dict[str, Any]] = None,
client_kwargs: Optional[Dict[str, Any]] = None,
table_settings: Optional[ClickHouseTableSettings] = None,
max_insert_block_rows: Optional[int] = None,
) -> None:
self._table = table
self._dsn = dsn
self._mode = mode
self._schema = schema
self._client_settings = client_settings or {}
self._client_kwargs = client_kwargs or {}
self._table_settings = table_settings or ClickHouseTableSettings()
self._max_insert_block_rows = max_insert_block_rows
self._table_dropped = False
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,
**self._client_kwargs,
)
def _generate_create_table_sql(
self,
schema: pyarrow.Schema,
) -> str:
engine = self._table_settings.engine
if self._table_settings.order_by is not None:
order_by = self._table_settings.order_by
else:
order_by = _pick_best_arrow_field_for_order_by(schema)
additional_clauses = []
if self._table_settings.partition_by is not None:
additional_clauses.append(
f"PARTITION BY {self._table_settings.partition_by}"
)
if self._table_settings.primary_key is not None:
additional_clauses.append(
f"PRIMARY KEY ({self._table_settings.primary_key})"
)
if self._table_settings.settings is not None:
additional_clauses.append(f"SETTINGS {self._table_settings.settings}")
additional_props = ""
if additional_clauses:
additional_props = "\n" + "\n".join(additional_clauses)
columns_def = []
for field in schema:
ch_type = _arrow_to_clickhouse_type(field)
columns_def.append(f"`{field.name}` {ch_type}")
columns_str = ",\n ".join(columns_def)
return self._CREATE_TABLE_TEMPLATE.format(
table_name=self._table,
columns=columns_str,
engine=engine,
order_by=order_by,
additional_props=additional_props,
)
def _table_exists(self, client) -> bool:
try:
result = client.query(
self._CHECK_TABLE_EXISTS_TEMPLATE.format(table_name=self._table)
)
if result is None:
return False
for helper in ("scalar", "first_item", "first_value"):
_check_import(
self, module="clickhouse_connect", package="clickhouse-connect"
)
from clickhouse_connect.driver.exceptions import Error as CHError
if hasattr(result, helper):
try:
return bool(getattr(result, helper)())
except (TypeError, ValueError, CHError) as exc:
# Helper exists but failed continue probing.
logger.debug(
"Helper %s failed: %s; will try fallbacks", helper, exc
)
# Fallback to inspecting common container attributes.
for attr in ("result_rows", "rows", "data"):
rows = getattr(result, attr, None)
if rows:
first = rows[0]
# Unwrap an extra layer if present (i.e. [[1]] or [(1,)])
if isinstance(first, (list, tuple)):
first = first[0] if first else 0
return bool(first)
return False
except Exception as e:
logger.warning(f"Could not verify if table {self._table} exists: {e}")
return False
def _get_existing_order_by(self, client) -> Optional[str]:
logger.debug(
f"Retrieving ORDER BY clause from SHOW CREATE TABLE for {self._table}"
)
try:
show_create_sql = self._SHOW_CREATE_TABLE_TEMPLATE.format(
table_name=self._table
)
result = client.command(show_create_sql)
ddl_str = str(result)
pattern = r"(?is)\border\s+by\s+(.*?)(?=\bengine\b|$)"
match = re.search(pattern, ddl_str)
if match:
return match.group(1).strip()
return None
except Exception as e:
logger.warning(
f"Could not retrieve SHOW CREATE TABLE for {self._table}: {e}"
)
return None
@property
def supports_distributed_writes(self) -> bool:
return True
def on_write_start(self, schema: Optional["pa.Schema"] = None) -> None:
client = None
try:
client = self._init_client()
table_exists = self._table_exists(client)
schema_required = (
# Overwrite always needs a schema because it recreates the table
self._mode == SinkMode.OVERWRITE
# For CREATE or APPEND we need a schema only when the table is
# absent and will therefore be created in this call.
or (
self._mode in (SinkMode.CREATE, SinkMode.APPEND)
and not table_exists
)
)
if schema_required and self._schema is None:
if self._mode == SinkMode.OVERWRITE:
raise ValueError(
f"Overwriting table {self._table} requires a userprovided schema."
)
else:
raise ValueError(
f"Table {self._table} does not exist in mode='{self._mode.name}' and "
"no schema was provided. Cannot create the table without a schema."
)
# OVERWRITE MODE
if self._mode == SinkMode.OVERWRITE:
# If we plan to overwrite, drop the table if it exists,
# then re-create it using the user-provided schema.
if table_exists and self._table_settings.order_by is None:
# Collect existing ORDER BY. This lets us preserve it if the user
# hasn't specified one explicitly.
existing_order_by = self._get_existing_order_by(client)
if existing_order_by is not None:
self._table_settings.order_by = existing_order_by
logger.info(
f"Reusing old ORDER BY from overwritten table:"
f" {existing_order_by}"
)
# DROP and CREATE the table.
drop_sql = self._DROP_TABLE_TEMPLATE.format(table_name=self._table)
logger.info(f"Mode=OVERWRITE => {drop_sql}")
client.command(drop_sql)
self._table_dropped = True
create_sql = self._generate_create_table_sql(self._schema)
client.command(create_sql)
# CREATE MODE
elif self._mode == SinkMode.CREATE:
# If table already exists in CREATE mode, fail immediately.
if table_exists:
msg = (
f"Table {self._table} already exists in mode='CREATE'. "
"Use mode='APPEND' or 'OVERWRITE' instead."
)
logger.error(msg)
raise ValueError(msg)
# Otherwise, create it (requires user-provided schema).
create_sql = self._generate_create_table_sql(self._schema)
client.command(create_sql)
# APPEND MODE
elif self._mode == SinkMode.APPEND:
if table_exists:
# Table exists validate or adopt ORDER BY.
existing_order_by = self._get_existing_order_by(client)
user_order_by = self._table_settings.order_by
if user_order_by is not None:
# The user explicitly set an order_by. Check if it conflicts:
if existing_order_by and existing_order_by != user_order_by:
raise ValueError(
f"Conflict with order_by. The existing table {self._table} "
f"has ORDER BY {existing_order_by}, but the user specified "
f"ORDER BY {user_order_by}. Please drop or overwrite the table, "
f"or use the same ordering."
)
elif existing_order_by:
self._table_settings.order_by = existing_order_by
logger.info(
f"Reusing existing ORDER BY for table {self._table}: {existing_order_by}"
)
else:
# Table doesn't exist, so create it with user schema
create_sql = self._generate_create_table_sql(self._schema)
client.command(create_sql)
except Exception as e:
logger.error(
f"Could not complete on_write_start for table {self._table}: {e}"
)
raise e
finally:
if client:
client.close()
def get_name(self) -> str:
return self.NAME
def write(
self,
blocks: Iterable[Block],
ctx: TaskContext,
) -> WriteReturnType:
client = self._init_client()
total_inserted = 0
try:
for block in blocks:
arrow_table = BlockAccessor.for_block(block).to_arrow()
row_count = arrow_table.num_rows
if self._schema is not None:
arrow_table = reorder_columns_by_schema(arrow_table, self._schema)
arrow_table = arrow_table.cast(self._schema, safe=True)
if self._max_insert_block_rows is not None:
max_chunk_size = self._max_insert_block_rows
else:
# If max_insert_block_rows is not set, insert all rows in one go
max_chunk_size = row_count if row_count > 0 else 1
offsets = list(range(0, row_count, max_chunk_size))
offsets.append(row_count)
for i in range(len(offsets) - 1):
start = offsets[i]
end = offsets[i + 1]
chunk = arrow_table.slice(start, end - start)
client.insert_arrow(self._table, chunk)
total_inserted += chunk.num_rows
except Exception as e:
logger.error(f"Failed to write block(s) to table {self._table}: {e}")
raise e
finally:
client.close()
return [total_inserted]