Files
ray-project--ray/python/ray/data/_internal/datasource/turbopuffer_datasink.py
T
2026-07-13 13:17:40 +08:00

428 lines
16 KiB
Python

"""
TurbopufferDatasink - Ray Data datasink for Turbopuffer vector database
Implementation following the pattern of MongoDatasink and Daft's Turbopuffer sink.
This is based on [Turbopuffer Write API](https://turbopuffer.com/docs/write)
"""
import logging
import os
from typing import TYPE_CHECKING, Iterable, Literal, Optional, Union
import pyarrow as pa
import pyarrow.compute as pc
from ray._common.retry import call_with_retry
from ray.data._internal.execution.interfaces import TaskContext
from ray.data._internal.planner.exchange.sort_task_spec import SortKey
from ray.data._internal.util import _check_import
from ray.data.block import Block, BlockAccessor
from ray.data.datasource.datasink import Datasink
if TYPE_CHECKING:
import turbopuffer
logger = logging.getLogger(__name__)
# Reserved column names for Turbopuffer
_ID_COLUMN = "id"
_VECTOR_COLUMN = "vector"
TURBOPUFFER_API_KEY_ENV_VAR = "TURBOPUFFER_API_KEY"
class TurbopufferDatasink(Datasink):
"""Turbopuffer Ray Datasink.
A Ray :class:`~ray.data.datasource.Datasink` for writing data into the
`Turbopuffer <https://turbopuffer.com/>`_ vector database.
Supports two modes of operation:
* **Single namespace** -- provide ``namespace`` to write all rows into one
Turbopuffer namespace.
* **Multi-namespace** -- provide ``namespace_column`` to route each row to
the namespace whose name is stored in that column. The column is
automatically dropped before the data is sent to Turbopuffer.
Exactly one of ``namespace`` or ``namespace_column`` must be supplied.
Args:
namespace: Name of the Turbopuffer namespace to write into.
Mutually exclusive with ``namespace_column``.
namespace_column: Name of a column whose values determine the
target namespace for each row. Rows are grouped by this column
and each group is written to its corresponding namespace. The
column is removed from the data before writing. Mutually
exclusive with ``namespace``.
region: Turbopuffer region identifier (for example,
``"gcp-us-central1"``). Mutually exclusive with ``base_url``.
Exactly one of ``region`` or ``base_url`` must be supplied.
base_url: Base URL for the Turbopuffer API (for example,
``"https://gcp-us-central1.turbopuffer.com"``). Mutually
exclusive with ``region``. Exactly one of ``region`` or
``base_url`` must be supplied.
api_key: Turbopuffer API key. If omitted, the value is read from the
``TURBOPUFFER_API_KEY`` environment variable.
schema: Optional Turbopuffer schema definition to pass along with
writes. If provided, it is forwarded as the ``schema`` argument
to ``namespace.write``.
id_column: Name of the column to treat as the document identifier.
Rows with null IDs are dropped before writing. Defaults to ``"id"``.
vector_column: Name of the column containing embedding vectors.
If this differs from ``"vector"``, it is renamed to ``"vector"``
before writing. Defaults to ``"vector"``.
batch_size: Maximum number of rows to include in a single Turbopuffer
write call (logical row batching; subject to Turbopuffer's
256MiB request-size limit). Defaults to ``10000``.
distance_metric: Distance metric for the namespace. Passed to
``namespace.write`` as the ``distance_metric`` argument.
Defaults to ``"cosine_distance"``.
concurrency: Unused; Ray Data controls write parallelism via
:meth:`~ray.data.Dataset.write_datasink` ``concurrency``.
Examples:
Write to a single namespace using a region:
.. testcode::
:skipif: True
import ray
from ray.data._internal.datasource.turbopuffer_datasink import (
TurbopufferDatasink,
)
ds = ray.data.range(100)
ds = ds.map_batches(lambda batch: {"id": batch["id"], "vector": ...})
ds.write_datasink(
TurbopufferDatasink(
namespace="my-namespace",
api_key="<YOUR_API_KEY>",
region="gcp-us-central1",
)
)
Write using a base URL instead of a region:
.. testcode::
:skipif: True
ds.write_datasink(
TurbopufferDatasink(
namespace="my-namespace",
api_key="<YOUR_API_KEY>",
base_url="https://gcp-us-central1.turbopuffer.com",
)
)
Write to multiple namespaces driven by a column:
.. testcode::
:skipif: True
ds.write_datasink(
TurbopufferDatasink(
namespace_column="tenant",
api_key="<YOUR_API_KEY>",
region="gcp-us-central1",
)
)
"""
def __init__(
self,
namespace: Optional[str] = None,
*,
namespace_column: Optional[str] = None,
region: Optional[str] = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
schema: Optional[dict] = None,
id_column: str = "id",
vector_column: str = "vector",
batch_size: int = 10000,
distance_metric: Literal[
"cosine_distance", "euclidean_distance"
] = "cosine_distance",
concurrency: Optional[int] = None,
):
_check_import(self, module="turbopuffer", package="turbopuffer")
# Validate namespace / namespace_column mutual exclusivity.
if namespace and namespace_column:
raise ValueError(
"Specify exactly one of 'namespace' or 'namespace_column', " "not both."
)
if not namespace and not namespace_column:
raise ValueError(
"Either 'namespace' or 'namespace_column' must be provided."
)
# Validate region / base_url mutual exclusivity.
if region is not None and base_url is not None:
raise ValueError("Specify exactly one of 'region' or 'base_url', not both.")
if region is None and base_url is None:
raise ValueError("Either 'region' or 'base_url' must be provided.")
# Store configuration
self.namespace = namespace
self.namespace_column = namespace_column
self.api_key = api_key or os.getenv(TURBOPUFFER_API_KEY_ENV_VAR)
self.region = region
self.base_url = base_url
self.schema = schema
self.id_column = id_column
self.vector_column = vector_column
self.batch_size = batch_size
self.distance_metric = distance_metric
# Validate column configuration
if self.id_column == self.vector_column:
raise ValueError(
"id_column and vector_column refer to the same column "
f"'{self.id_column}'. They must be distinct."
)
if self.namespace_column and self.namespace_column in (
self.id_column,
self.vector_column,
):
raise ValueError(
f"namespace_column '{self.namespace_column}' must not be the "
f"same as id_column ('{self.id_column}') or vector_column "
f"('{self.vector_column}')."
)
# Validate API key
if not self.api_key:
raise ValueError(
"API key is required. Provide via api_key parameter or "
"TURBOPUFFER_API_KEY environment variable"
)
# Initialize client
self._client = None
def __getstate__(self) -> dict:
"""Exclude `_client` during pickling."""
state = self.__dict__.copy()
state.pop("_client", None)
return state
def __setstate__(self, state: dict) -> None:
self.__dict__.update(state)
self._client = None
def _get_client(self):
"""Lazy initialize Turbopuffer client."""
if self._client is None:
import turbopuffer
kwargs = {"api_key": self.api_key}
if self.region is not None:
kwargs["region"] = self.region
else:
kwargs["base_url"] = self.base_url
self._client = turbopuffer.Turbopuffer(**kwargs)
return self._client
def write(
self,
blocks: Iterable[Block],
ctx: TaskContext,
) -> None:
"""
Write blocks to Turbopuffer in a streaming fashion.
For memory efficiency, blocks are processed one at a time rather than
concatenating all blocks into a single large table. This follows the
pattern used by ClickHouseDatasink.
Each block is prepared (columns renamed, null IDs filtered), then
written in batches of ``batch_size``.
When ``namespace_column`` is set, each block is grouped by the
namespace column and each group is written to its corresponding
Turbopuffer namespace.
"""
client = self._get_client()
for block in blocks:
accessor = BlockAccessor.for_block(block)
table = accessor.to_arrow()
if table.num_rows == 0:
continue
if self.namespace_column:
# Multi-namespace: group by namespace column, write to each.
self._write_multi_namespace(client, table)
else:
# Single namespace.
table = self._prepare_arrow_table(table)
if table.num_rows == 0:
continue
ns = client.namespace(self.namespace)
for batch in table.to_batches(max_chunksize=self.batch_size):
self._write_batch_with_retry(ns, batch, self.namespace)
def _rename_column_if_needed(
self,
table: pa.Table,
source_column: str,
target_column: str,
column_type: str,
) -> pa.Table:
"""
Rename a column in the table if it differs from the target name.
Args:
table: The Arrow table to modify.
source_column: The current column name in the table.
target_column: The required column name for Turbopuffer.
column_type: Human-readable type for error messages (e.g., "ID", "Vector").
Returns:
The table with the column renamed, or the original table if no rename needed.
Raises:
ValueError: If source column is missing or target column already exists.
"""
if source_column not in table.column_names:
raise ValueError(
f"{column_type} column '{source_column}' not found in table"
)
# No rename needed if source and target are the same
if source_column == target_column:
return table
if target_column in table.column_names:
raise ValueError(
f"Table already has a '{target_column}' column; cannot also rename "
f"'{source_column}' to '{target_column}'. Please disambiguate your schema."
)
return BlockAccessor.for_block(table).rename_columns(
{source_column: target_column}
)
def _prepare_arrow_table(self, table: pa.Table) -> pa.Table:
"""
Prepare Arrow table for Turbopuffer write.
1. Rename ID column to "id" if needed
2. Rename vector column to "vector" if needed
3. Filter out rows with null IDs
"""
table = self._rename_column_if_needed(table, self.id_column, _ID_COLUMN, "ID")
table = self._rename_column_if_needed(
table, self.vector_column, _VECTOR_COLUMN, "Vector"
)
# Filter out rows with null IDs
if _ID_COLUMN in table.column_names:
table = table.filter(pc.is_valid(table.column(_ID_COLUMN)))
return table
def _write_multi_namespace(
self, client: "turbopuffer.Turbopuffer", table: pa.Table
) -> None:
"""Group rows by ``namespace_column`` and write each group to its namespace.
Uses :meth:`BlockAccessor._iter_groups_sorted` for efficient
zero-copy slicing by group.
"""
group_col_name = self.namespace_column
if group_col_name not in table.column_names:
raise ValueError(
f"Namespace column '{group_col_name}' not found in table. "
f"Available columns: {table.column_names}"
)
# Reject null namespace values early -- we cannot route them.
ns_col = table.column(group_col_name)
if pc.any(pc.is_null(ns_col)).as_py():
raise ValueError(
f"Namespace column '{group_col_name}' contains null values; "
"fill or drop them before writing with namespace_column."
)
# Sort by the namespace column so _iter_groups_sorted can yield
# contiguous zero-copy slices for each unique namespace value.
sort_key = SortKey(key=group_col_name, descending=False)
block_accessor = BlockAccessor.for_block(
BlockAccessor.for_block(table).sort(sort_key)
)
for (namespace_name,), group_table in block_accessor._iter_groups_sorted(
sort_key
):
# Drop the namespace column -- it is routing metadata, not data.
group_table = group_table.drop(group_col_name)
# Prepare (rename id/vector columns, filter null IDs).
group_table = self._prepare_arrow_table(group_table)
if group_table.num_rows == 0:
continue
ns = client.namespace(namespace_name)
for batch in group_table.to_batches(max_chunksize=self.batch_size):
self._write_batch_with_retry(ns, batch, namespace_name)
def _transform_to_turbopuffer_format(
self, table: Union[pa.Table, pa.RecordBatch]
) -> dict:
if _ID_COLUMN not in table.column_names:
raise ValueError(f"Table must have '{_ID_COLUMN}' column")
# Cast 16-byte binary ID column to native UUID type for Turbopuffer performance.
# Native UUIDs are 16 bytes vs 36 bytes for string-encoded UUIDs.
# See: https://turbopuffer.com/docs/performance
id_col = table.column(_ID_COLUMN)
if pa.types.is_fixed_size_binary(id_col.type) and id_col.type.byte_width == 16:
# Cast fixed_size_binary(16) to uuid type
uuid_col = id_col.cast(pa.uuid())
table = table.set_column(
table.schema.get_field_index(_ID_COLUMN), _ID_COLUMN, uuid_col
)
# to_pydict() on UuidArray automatically returns uuid.UUID objects
return table.to_pydict()
def _write_batch_with_retry(
self,
namespace: "turbopuffer.Namespace",
batch: pa.Table,
namespace_name: Optional[str] = None,
):
"""Write a single batch with exponential backoff retry.
Args:
namespace: The Turbopuffer namespace object to write to.
batch: Arrow table or record-batch to write.
namespace_name: Human-readable namespace name for log messages.
Falls back to ``self.namespace`` when not provided.
"""
ns_label = namespace_name or self.namespace
try:
batch_data = self._transform_to_turbopuffer_format(batch)
call_with_retry(
lambda: namespace.write(
upsert_columns=batch_data,
schema=self.schema,
distance_metric=self.distance_metric,
),
description=f"write batch to namespace '{ns_label}'",
max_attempts=5,
max_backoff_s=32,
)
except Exception as e:
logger.error(f"Write failed for namespace '{ns_label}' after retries: {e}")
raise