blob: c3932aae6c266864a211a0be091b8023b5a35e9b [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Bucket-aligned join on Ray for two co-bucketed Paimon tables.
Same key -> same bucket on both sides, so each bucket is read and joined in its own
Ray task with no global shuffle -- the no-shuffle alternative to ``ray.data.join``.
"""
from typing import Any, Dict, List, Optional
from pypaimon.ray.join_common import (
OnSpec,
get_table as _shared_get_table,
key_type as _key_type,
norm_on as _norm,
pin_latest_snapshot,
read_splits,
)
# The table cache now lives in join_common; keep the old name for callers/tests.
from pypaimon.ray.join_common import _TABLE_CACHE # noqa: F401
__all__ = ["bucket_join"]
def _bucketing(table):
# Resolved bucket keys (a PK table without an explicit bucket-key buckets by its
# trimmed primary key) plus the bucket function: same key co-locates only under both.
return (table.options.bucket(),
list(table.table_schema.bucket_keys),
table.table_schema.options.get("bucket-function.type", "default"))
def _get_table(table_id, catalog_options, schema_id=None):
return _shared_get_table(table_id, catalog_options, schema_id, "bucket_join")
def _plan_splits_by_bucket(table_id, catalog_options, projection, expected_total_buckets):
"""Plan the manifest and group splits by bucket (driver-side).
Returns ``(by_bucket, schema_id)``: the schema id of the table instance that
built this plan, so workers validate against the schema the plan was made with
(not a possibly-newer one loaded earlier by the caller).
"""
table = _get_table(table_id, catalog_options) # fresh, latest schema
schema_id = table.table_schema.id
# Pin the guard and the split plan to one snapshot, else a commit between the two
# manifest reads could slip stale-bucket files past the guard.
if pin_latest_snapshot(table) is None:
return {}, schema_id
rb = table.new_read_builder()
scan = (rb.with_projection(projection) if projection is not None else rb).new_scan()
# Guard against a rescaled table (old files under a different total_buckets, which
# splits -- bucket only -- can't tell apart); read the entries once for it.
fs = scan.file_scanner
entries = fs.plan_files()
stale = {e.total_buckets for e in entries if e.total_buckets != expected_total_buckets}
if stale:
raise ValueError(
f"bucket_join needs {table_id} fully in bucket count {expected_total_buckets}, "
f"but files exist under {sorted(stale)} (rescale in progress); rewrite first.")
by_bucket = {}
for s in scan.plan().splits():
by_bucket.setdefault(s.bucket, []).append(s)
return by_bucket, schema_id
def _read_splits(table_id, catalog_options, projection, splits, schema_id):
return read_splits(table_id, catalog_options, projection, splits, schema_id, "bucket_join")
def bucket_join(
left: str,
right: str,
catalog_options: Dict[str, str],
*,
on: OnSpec,
left_projection: Optional[List[str]] = None,
right_projection: Optional[List[str]] = None,
join_type: str = "inner",
ray_remote_args: Optional[Dict[str, Any]] = None,
) -> "ray.data.Dataset":
"""Join two co-bucketed tables (same bucket count + bucket-key, joined on the
bucket-key) with no global shuffle. ``on`` must equal the bucket-key. The two
sides must not share column names other than the join key (pyarrow ``join``
would otherwise collide). Returns a ``ray.data.Dataset``."""
import ray
from pypaimon.catalog.catalog_factory import CatalogFactory
if not hasattr(ray.data, "from_arrow_refs"):
raise RuntimeError(
"bucket_join needs a Ray version with ray.data.from_arrow_refs; "
f"installed ray is {ray.__version__}.")
on_cols = _norm(on)
cat = CatalogFactory.create(catalog_options)
ltable, rtable = cat.get_table(left), cat.get_table(right)
lcount, lkey, lfunc = _bucketing(ltable)
rcount, rkey, rfunc = _bucketing(rtable)
if ltable.partition_keys or rtable.partition_keys:
# Bucket numbers are per-partition, so the same bucket id lives in every
# partition -- grouping splits by bucket alone would join across partitions.
# Supporting this needs grouping by (partition, bucket); not done yet.
raise ValueError(
"bucket_join does not support partitioned tables yet; got partition keys "
f"{left}={ltable.partition_keys}, {right}={rtable.partition_keys}.")
if not lcount or lcount <= 0 or not rcount or rcount <= 0:
raise ValueError(
"bucket_join requires both tables to be fixed-bucket (bucket > 0); "
f"got {left}={lcount}, {right}={rcount}.")
if lcount != rcount:
raise ValueError(
f"bucket_join requires the same bucket count; {left}={lcount}, {right}={rcount}.")
if lkey != rkey:
raise ValueError(
f"bucket_join requires the same bucket-key; {left}={lkey}, {right}={rkey}.")
if lfunc != rfunc:
# Different bucket functions hash the same key to different buckets.
raise ValueError(
f"bucket_join requires the same bucket-function.type; {left}={lfunc}, {right}={rfunc}.")
if on_cols != lkey:
raise ValueError(
f"bucket_join requires the join key to be the bucket-key {lkey}; got on={on_cols}. "
"Equal keys only co-locate by bucket when joining on the bucket-key "
"(the comparison is order-sensitive for composite keys).")
# Same name isn't enough: differing key types (INT vs BIGINT) can hash apart and
# silently drop matches (types compared without nullability).
key_type_mismatch = [
(c, _key_type(ltable, c), _key_type(rtable, c))
for c in on_cols
if _key_type(ltable, c) != _key_type(rtable, c)
]
if key_type_mismatch:
raise ValueError(
"bucket_join requires the bucket-key columns to have the same type on both "
f"sides; mismatched (column, left, right): {key_type_mismatch}.")
if join_type != "inner":
# Outer joins would need the union of buckets (a bucket missing on one side
# still emits rows); only inner is correct with the per-bucket intersection.
raise ValueError(f"bucket_join currently supports only join_type='inner'; got {join_type!r}.")
# The join key must survive projection on both sides, or ``Table.join`` has no key.
if left_projection is not None and not set(on_cols) <= set(left_projection):
raise ValueError(
f"left_projection must include the join key {on_cols}; got {left_projection}.")
if right_projection is not None and not set(on_cols) <= set(right_projection):
raise ValueError(
f"right_projection must include the join key {on_cols}; got {right_projection}.")
# The two sides must not share non-key columns, or pyarrow's join collides on them.
# Check up front (against the projected columns) instead of failing inside a task.
lcols = left_projection if left_projection is not None else ltable.field_names
rcols = right_projection if right_projection is not None else rtable.field_names
collisions = sorted((set(lcols) & set(rcols)) - set(on_cols))
if collisions:
raise ValueError(
f"bucket_join sides must not share columns other than the join key {on_cols}; "
f"both have {collisions}. Project or rename them away.")
# Plan each side's manifest once (driver-side, split metadata only -- the join
# results stay distributed below), then dispatch per-bucket splits to the tasks.
left_by_bucket, l_schema_id = _plan_splits_by_bucket(
left, catalog_options, left_projection, lcount)
right_by_bucket, r_schema_id = _plan_splits_by_bucket(
right, catalog_options, right_projection, rcount)
def _join_bucket(left_splits, right_splits):
left_t = _read_splits(left, catalog_options, left_projection, left_splits, l_schema_id)
right_t = _read_splits(right, catalog_options, right_projection, right_splits, r_schema_id)
return left_t.join(right_t, keys=on_cols, join_type=join_type)
# ``@ray.remote()`` (empty parens) is rejected by Ray, so wrap conditionally.
remote_fn = ray.remote(**ray_remote_args)(_join_bucket) if ray_remote_args else ray.remote(_join_bucket)
# Inner join: only buckets present on both sides can match.
buckets = sorted(set(left_by_bucket) & set(right_by_bucket))
if not buckets:
# No shared bucket: empty result, but keep the join schema (join two empties).
empty = _read_splits(left, catalog_options, left_projection, [], l_schema_id).join(
_read_splits(right, catalog_options, right_projection, [], r_schema_id),
keys=on_cols, join_type=join_type)
return ray.data.from_arrow(empty)
# Keep each bucket's result as a distributed object ref -- never pulled into the driver.
refs = [remote_fn.remote(left_by_bucket[b], right_by_bucket[b]) for b in buckets]
return ray.data.from_arrow_refs(refs)