This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 7dc65b9f61 [python][ray] Add no-shuffle bucket join for co-bucketed 
tables (#8397)
7dc65b9f61 is described below

commit 7dc65b9f6175629f6eb505369de41f8d15a277e3
Author: XiaoHongbo <[email protected]>
AuthorDate: Fri Jul 3 21:48:17 2026 +0800

    [python][ray] Add no-shuffle bucket join for co-bucketed tables (#8397)
---
 docs/docs/pypaimon/ray-data.md                     |  52 ++++
 paimon-python/pypaimon/ray/__init__.py             |   2 +
 paimon-python/pypaimon/ray/bucket_join.py          | 241 +++++++++++++++
 .../pypaimon/tests/ray_bucket_join_test.py         | 342 +++++++++++++++++++++
 4 files changed, 637 insertions(+)

diff --git a/docs/docs/pypaimon/ray-data.md b/docs/docs/pypaimon/ray-data.md
index 960ab84213..fb56c54ac6 100644
--- a/docs/docs/pypaimon/ray-data.md
+++ b/docs/docs/pypaimon/ray-data.md
@@ -339,6 +339,58 @@ table_write = (
 table_write.write_ray(ray_dataset)
 ```
 
+## Bucket Join
+
+`bucket_join` joins two **co-bucketed** tables (same bucket count and the same
+bucket-key) on the bucket-key, with **no global shuffle**: the same key lands 
in
+the same bucket on both sides, so each bucket is read and joined in its own Ray
+task. It returns a `ray.data.Dataset` whose results stay distributed (never
+pulled into the driver).
+
+A common use is looking up a global `_ROW_ID` for a batch of keys without a
+shuffle join against a large table: keep a small co-bucketed `(key, _ROW_ID)`
+side table, `bucket_join` the incoming keys against it, then feed the resulting
+row ids into a row-id update.
+
+```python
+from pypaimon.ray import bucket_join
+
+ds = bucket_join(
+    left="database_name.incoming_keys",   # co-bucketed table identifier
+    right="database_name.key_rowid",       # co-bucketed table identifier
+    catalog_options={"warehouse": "/path/to/warehouse"},
+    on="url",                              # must equal the bucket-key
+    left_projection=["url"],               # optional; must keep the join key
+    right_projection=["url", "row_id"],    # optional; must keep the join key
+)
+# ds: ray.data.Dataset of the joined rows, e.g. {"url": ..., "row_id": ...}
+```
+
+**Parameters:**
+- `left` / `right`: identifiers of the two co-bucketed tables to join.
+- `on`: the join key(s). Must be exactly the bucket-key — equal keys only
+  co-locate by bucket when joining on the bucket-key.
+- `left_projection` / `right_projection`: optional column projections applied 
on
+  read. If given, each must include the join key.
+- `join_type`: only `"inner"` is supported (an outer join would need the union
+  of buckets, which per-bucket intersection cannot produce).
+- `ray_remote_args`: Ray remote options applied to each per-bucket join task.
+
+**Returns:** a `ray.data.Dataset` of the joined rows.
+
+**Notes:**
+- Both tables must be fixed-bucket (`bucket > 0`) with the same bucket count 
and
+  the same bucket-key (same column names, order, and types); otherwise
+  `bucket_join` raises. For primary-key tables that do not set `bucket-key`
+  explicitly, the bucket-key resolves to the (partition-trimmed) primary key.
+- The two sides must not share columns other than the join key, or the
+  underlying pyarrow join would collide; project them away with
+  `left_projection` / `right_projection` first.
+- Each side is planned at its own latest snapshot, and one bucket is joined by 
a
+  single Ray task that reads the whole bucket into memory. Choose a bucket 
count
+  that spreads keys evenly to avoid skewed, memory-heavy tasks.
+- Partitioned tables are not supported yet (bucket ids are per-partition).
+
 ## Merge Into
 
 `merge_into` updates or deletes matched rows and optionally inserts unmatched
diff --git a/paimon-python/pypaimon/ray/__init__.py 
b/paimon-python/pypaimon/ray/__init__.py
index 4280187956..ba88fe5c7a 100644
--- a/paimon-python/pypaimon/ray/__init__.py
+++ b/paimon-python/pypaimon/ray/__init__.py
@@ -16,6 +16,7 @@
 # under the License.
 
 from pypaimon.ray.ray_paimon import read_paimon, write_paimon
+from pypaimon.ray.bucket_join import bucket_join
 from pypaimon.ray.data_evolution_merge_into import (
     WhenMatched,
     WhenNotMatched,
@@ -30,6 +31,7 @@ from pypaimon.ray.data_evolution_merge_transform import (
 __all__ = [
     "read_paimon",
     "write_paimon",
+    "bucket_join",
     "merge_into",
     "WhenMatched",
     "WhenNotMatched",
diff --git a/paimon-python/pypaimon/ray/bucket_join.py 
b/paimon-python/pypaimon/ray/bucket_join.py
new file mode 100644
index 0000000000..818643ec95
--- /dev/null
+++ b/paimon-python/pypaimon/ray/bucket_join.py
@@ -0,0 +1,241 @@
+#  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``.
+"""
+
+import threading
+from typing import Any, Dict, List, Optional, Sequence, Union
+
+__all__ = ["bucket_join"]
+
+OnSpec = Union[str, Sequence[str]]
+
+
+def _norm(on: OnSpec) -> List[str]:
+    return [on] if isinstance(on, str) else list(on)
+
+
+def _key_type(table, col):
+    # Logical type without nullability -- a present key hashes the same either 
way.
+    return str(table.field_dict[col].type).replace(" NOT NULL", "")
+
+
+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"))
+
+
+# Per-worker table cache, keyed by schema id (so a schema change invalidates 
it) and
+# lock-guarded against concurrent tasks. Planning always loads a fresh table.
+_TABLE_CACHE: Dict = {}
+_TABLE_CACHE_LOCK = threading.Lock()
+
+
+def _get_table(table_id, catalog_options, schema_id=None):
+    from pypaimon.catalog.catalog_factory import CatalogFactory
+    if schema_id is None:  # planning: always load the latest schema
+        return CatalogFactory.create(catalog_options).get_table(table_id)
+    key = (table_id, tuple(sorted(catalog_options.items())), schema_id)
+    with _TABLE_CACHE_LOCK:
+        table = _TABLE_CACHE.get(key)
+        if table is None:
+            table = CatalogFactory.create(catalog_options).get_table(table_id)
+            if table.table_schema.id != schema_id:
+                # get_table loads the latest schema; a mismatch means the 
schema moved
+                # after the driver planned, so the split plan is stale -- fail 
fast.
+                raise ValueError(
+                    f"{table_id} schema changed during bucket_join (planned 
{schema_id}, "
+                    f"now {table.table_schema.id}); retry.")
+            _TABLE_CACHE[key] = table
+        return table
+
+
+def _read_builder(table_id, catalog_options, projection, schema_id=None):
+    rb = _get_table(table_id, catalog_options, schema_id).new_read_builder()
+    return rb.with_projection(projection) if projection is not None else rb
+
+
+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).
+    """
+    from pypaimon.common.options.core_options import CoreOptions
+    table = _get_table(table_id, catalog_options)  # fresh, latest schema
+    schema_id = table.table_schema.id
+    snapshot = table.snapshot_manager().get_latest_snapshot()
+    if snapshot is None:
+        return {}, 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. Drop any 
existing
+    # scan.mode / point-in-time options first so snapshot-id doesn't clash 
with them.
+    opts = table.options.options
+    for key in (CoreOptions.SCAN_MODE, CoreOptions.SCAN_SNAPSHOT_ID,
+                CoreOptions.SCAN_TAG_NAME, CoreOptions.SCAN_WATERMARK,
+                CoreOptions.SCAN_TIMESTAMP, CoreOptions.SCAN_TIMESTAMP_MILLIS,
+                CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP,
+                CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS,
+                CoreOptions.SCAN_CREATION_TIME_MILLIS):
+        opts.data.pop(key.key(), None)
+    opts.set(CoreOptions.SCAN_SNAPSHOT_ID, snapshot.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.")
+    # Reuse those entries: scan.plan() re-reads plan_files() (append/pk) 
otherwise.
+    fs.plan_files = lambda: entries
+    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):
+    # Snapshot-independent but schema-dependent -> cache by schema id (in 
_get_table).
+    return _read_builder(
+        table_id, catalog_options, projection, 
schema_id).new_read().to_arrow(splits)
+
+
+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)
diff --git a/paimon-python/pypaimon/tests/ray_bucket_join_test.py 
b/paimon-python/pypaimon/tests/ray_bucket_join_test.py
new file mode 100644
index 0000000000..398ac2a37c
--- /dev/null
+++ b/paimon-python/pypaimon/tests/ray_bucket_join_test.py
@@ -0,0 +1,342 @@
+#  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.
+
+import os
+import shutil
+import tempfile
+import unittest
+
+import pyarrow as pa
+import pytest
+
+pypaimon = pytest.importorskip("pypaimon")
+ray = pytest.importorskip("ray")
+
+import importlib
+from unittest import mock
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.ray import bucket_join
+
+# The package attribute ``pypaimon.ray.bucket_join`` is the function (it 
shadows the
+# submodule); import the module explicitly to reach its internal helpers.
+bjmod = importlib.import_module("pypaimon.ray.bucket_join")
+
+
+class RayBucketJoinTest(unittest.TestCase):
+    """Bucket-aligned join between two HASH_FIXED tables must equal a global 
join,
+    with each bucket joined only against the same bucket (no cross-bucket 
shuffle)."""
+
+    NUM_BUCKETS = 8
+
+    @classmethod
+    def setUpClass(cls):
+        cls.tempdir = tempfile.mkdtemp()
+        cls.catalog_options = {"warehouse": os.path.join(cls.tempdir, "wh")}
+        cls.catalog = CatalogFactory.create(cls.catalog_options)
+        cls.catalog.create_database("default", True)
+        if not ray.is_initialized():
+            ray.init(ignore_reinit_error=True, num_cpus=4)
+
+    @classmethod
+    def tearDownClass(cls):
+        try:
+            if ray.is_initialized():
+                ray.shutdown()
+        except Exception:
+            pass
+        shutil.rmtree(cls.tempdir, ignore_errors=True)
+
+    def _bucketed_table(self, name, schema, key, data, primary_keys=None, 
extra_opts=None):
+        opts = {"bucket": str(self.NUM_BUCKETS)}
+        if primary_keys is None:  # append table: bucket-key must be explicit
+            opts["bucket-key"] = key
+        if extra_opts:
+            opts.update(extra_opts)
+        # PK table: leave bucket-key unset so it defaults to the primary key.
+        self.catalog.create_table(
+            name,
+            Schema.from_pyarrow_schema(schema, primary_keys=primary_keys, 
options=opts),
+            False)
+        t = self.catalog.get_table(name)
+        wb = t.new_batch_write_builder()
+        w = wb.new_write()
+        w.write_arrow(data)
+        wb.new_commit().commit(w.prepare_commit())
+        w.close()
+        return name
+
+    def _create_bucketed(self, name, schema, key, num_buckets,
+                         partition_keys=None, extra_opts=None):
+        opts = {"bucket": str(num_buckets), "bucket-key": key}
+        if extra_opts:
+            opts.update(extra_opts)
+        self.catalog.create_table(
+            name,
+            Schema.from_pyarrow_schema(schema, partition_keys=partition_keys, 
options=opts),
+            False)
+        return name
+
+    def test_bucket_join_matches_global_join(self):
+        loc_schema = pa.schema([("url", pa.string()), ("row_id", pa.int64())])
+        in_schema = pa.schema([("url", pa.string())])
+        self._bucketed_table(
+            "default.locator", loc_schema, "url",
+            pa.Table.from_pydict({"url": [f"u{i}" for i in range(1000)],
+                                  "row_id": list(range(1000))}, 
schema=loc_schema))
+        self._bucketed_table(
+            "default.input", in_schema, "url",
+            pa.Table.from_pydict({"url": [f"u{i}" for i in range(0, 400)]}, 
schema=in_schema))
+
+        ds = bucket_join(
+            "default.input", "default.locator", self.catalog_options,
+            on="url", left_projection=["url"], right_projection=["url", 
"row_id"])
+        got = {r["url"]: r["row_id"] for r in ds.take_all()}
+
+        # every input url (u0..u399) is matched to its locator row_id
+        self.assertEqual(set(got), {f"u{i}" for i in range(400)})
+        self.assertEqual(got["u0"], 0)
+        self.assertEqual(got["u399"], 399)
+        self.assertTrue(all(got[f"u{i}"] == i for i in range(400)))
+
+    def test_fan_out_one_url_many_row_ids(self):
+        # A url may map to several locator rows; every match must be emitted.
+        loc_schema = pa.schema([("url", pa.string()), ("row_id", pa.int64())])
+        in_schema = pa.schema([("url", pa.string())])
+        self._bucketed_table(
+            "default.loc_fan", loc_schema, "url",
+            pa.Table.from_pydict({"url": ["u0", "u0", "u1"], "row_id": [0, 1, 
2]},
+                                 schema=loc_schema))
+        self._bucketed_table(
+            "default.in_fan", in_schema, "url",
+            pa.Table.from_pydict({"url": ["u0"]}, schema=in_schema))
+        ds = bucket_join(
+            "default.in_fan", "default.loc_fan", self.catalog_options,
+            on="url", left_projection=["url"], right_projection=["url", 
"row_id"])
+        self.assertEqual(sorted(r["row_id"] for r in ds.take_all()), [0, 1])
+
+    def test_join_with_explicit_scan_mode(self):
+        # An explicit scan.mode on the tables must not clash with the internal
+        # snapshot pin during planning.
+        loc = pa.schema([("url", pa.string()), ("row_id", pa.int64())])
+        ins = pa.schema([("url", pa.string())])
+        mode = {"scan.mode": "latest-full"}
+        self._bucketed_table(
+            "default.sm_loc", loc, "url",
+            pa.Table.from_pydict({"url": [f"u{i}" for i in range(50)],
+                                  "row_id": list(range(50))}, schema=loc),
+            extra_opts=mode)
+        self._bucketed_table(
+            "default.sm_in", ins, "url",
+            pa.Table.from_pydict({"url": [f"u{i}" for i in range(20)]}, 
schema=ins),
+            extra_opts=mode)
+        ds = bucket_join(
+            "default.sm_in", "default.sm_loc", self.catalog_options,
+            on="url", left_projection=["url"], right_projection=["url", 
"row_id"])
+        got = {r["url"]: r["row_id"] for r in ds.take_all()}
+        self.assertEqual(got, {f"u{i}": i for i in range(20)})
+
+    def test_dispatches_one_task_per_shared_bucket(self):
+        # No cross-bucket shuffle: exactly one Ray task (object ref) per 
shared bucket.
+        loc = pa.schema([("url", pa.string()), ("row_id", pa.int64())])
+        ins = pa.schema([("url", pa.string())])
+        self._bucketed_table(
+            "default.disp_loc", loc, "url",
+            pa.Table.from_pydict({"url": [f"u{i}" for i in range(200)],
+                                  "row_id": list(range(200))}, schema=loc))
+        self._bucketed_table(
+            "default.disp_in", ins, "url",
+            pa.Table.from_pydict({"url": [f"u{i}" for i in range(100)]}, 
schema=ins))
+        lbb, _ = bjmod._plan_splits_by_bucket(
+            "default.disp_in", self.catalog_options, ["url"], self.NUM_BUCKETS)
+        rbb, _ = bjmod._plan_splits_by_bucket(
+            "default.disp_loc", self.catalog_options, ["url", "row_id"], 
self.NUM_BUCKETS)
+        shared = set(lbb) & set(rbb)
+        self.assertGreater(len(shared), 1)  # genuinely spread across buckets
+
+        captured = {}
+        real = ray.data.from_arrow_refs
+
+        def spy(refs):
+            captured["n"] = len(refs)
+            return real(refs)
+
+        with mock.patch.object(ray.data, "from_arrow_refs", spy):
+            ds = bucket_join(
+                "default.disp_in", "default.disp_loc", self.catalog_options,
+                on="url", left_projection=["url"], right_projection=["url", 
"row_id"])
+            ds.take_all()
+        self.assertEqual(captured["n"], len(shared))
+
+    def test_composite_bucket_key(self):
+        # Happy path for a multi-column bucket-key joined on both columns.
+        loc = pa.schema([("a", pa.string()), ("b", pa.int64()), ("row_id", 
pa.int64())])
+        ins = pa.schema([("a", pa.string()), ("b", pa.int64())])
+        self._bucketed_table(
+            "default.comp_loc", loc, "a,b",
+            pa.Table.from_pydict({"a": [f"k{i}" for i in range(50)], "b": 
list(range(50)),
+                                  "row_id": list(range(50))}, schema=loc))
+        self._bucketed_table(
+            "default.comp_in", ins, "a,b",
+            pa.Table.from_pydict({"a": [f"k{i}" for i in range(20)], "b": 
list(range(20))},
+                                 schema=ins))
+        ds = bucket_join(
+            "default.comp_in", "default.comp_loc", self.catalog_options,
+            on=["a", "b"], left_projection=["a", "b"], right_projection=["a", 
"b", "row_id"])
+        got = {(r["a"], r["b"]): r["row_id"] for r in ds.take_all()}
+        self.assertEqual(len(got), 20)
+        self.assertTrue(all(got[(f"k{i}", i)] == i for i in range(20)))
+
+    def test_read_cache_by_schema_id_and_staleness(self):
+        # Same schema id -> cached (reused). A schema id that is no longer 
current means
+        # the plan is stale -> fail fast instead of reading with the wrong 
schema.
+        self._create_bucketed("default.cache_t", pa.schema([("url", 
pa.string())]), "url", 8)
+        bjmod._TABLE_CACHE.clear()
+        opts = self.catalog_options
+        sid = self.catalog.get_table("default.cache_t").table_schema.id
+        t1 = bjmod._get_table("default.cache_t", opts, sid)
+        t2 = bjmod._get_table("default.cache_t", opts, sid)
+        self.assertIs(t1, t2)                       # same schema id -> cached
+        with self.assertRaises(ValueError):         # stale plan: requested 
schema not current
+            bjmod._get_table("default.cache_t", opts, sid + 99)
+
+    def test_empty_result_keeps_schema(self):
+        # No shared bucket -> 0 rows, but the join schema must survive.
+        loc_schema = pa.schema([("url", pa.string()), ("row_id", pa.int64())])
+        in_schema = pa.schema([("url", pa.string())])
+        self._bucketed_table(
+            "default.loc_empty", loc_schema, "url",
+            pa.Table.from_pydict({"url": ["u0", "u1"], "row_id": [0, 1]}, 
schema=loc_schema))
+        self._bucketed_table(
+            "default.in_empty", in_schema, "url",
+            pa.Table.from_pydict({"url": []}, schema=in_schema))  # no rows -> 
no buckets
+        ds = bucket_join(
+            "default.in_empty", "default.loc_empty", self.catalog_options,
+            on="url", left_projection=["url"], right_projection=["url", 
"row_id"])
+        self.assertEqual(ds.count(), 0)
+        self.assertIn("row_id", ds.schema().names)
+
+    def test_rejects_different_bucket_count(self):
+        sch = pa.schema([("url", pa.string())])
+        self._create_bucketed("default.cnt_8", sch, "url", 8)
+        self._create_bucketed("default.cnt_16", sch, "url", 16)
+        with self.assertRaises(ValueError):
+            bucket_join("default.cnt_8", "default.cnt_16", 
self.catalog_options, on="url")
+
+    def test_rejects_different_bucket_key(self):
+        sch = pa.schema([("url", pa.string()), ("k", pa.string())])
+        self._create_bucketed("default.by_url", sch, "url", 8)
+        self._create_bucketed("default.by_k", sch, "k", 8)
+        with self.assertRaises(ValueError):
+            bucket_join("default.by_url", "default.by_k", 
self.catalog_options, on="url")
+
+    def test_rejects_join_key_not_bucket_key(self):
+        sch = pa.schema([("url", pa.string()), ("k", pa.string())])
+        self._create_bucketed("default.k1", sch, "url", 8)
+        self._create_bucketed("default.k2", sch, "url", 8)
+        with self.assertRaises(ValueError):  # on=k but bucket-key=url
+            bucket_join("default.k1", "default.k2", self.catalog_options, 
on="k")
+
+    def test_primary_key_default_bucket_key(self):
+        # PK tables bucket by their primary key without an explicit bucket-key 
option;
+        # bucket_join must resolve that and join on the PK.
+        loc_schema = pa.schema([("url", pa.string()), ("row_id", pa.int64())])
+        in_schema = pa.schema([("url", pa.string())])
+        self._bucketed_table(
+            "default.pk_loc", loc_schema, "url",
+            pa.Table.from_pydict({"url": [f"u{i}" for i in range(100)],
+                                  "row_id": list(range(100))}, 
schema=loc_schema),
+            primary_keys=["url"])
+        self._bucketed_table(
+            "default.pk_in", in_schema, "url",
+            pa.Table.from_pydict({"url": [f"u{i}" for i in range(40)]}, 
schema=in_schema),
+            primary_keys=["url"])
+        ds = bucket_join(
+            "default.pk_in", "default.pk_loc", self.catalog_options,
+            on="url", left_projection=["url"], right_projection=["url", 
"row_id"])
+        got = {r["url"]: r["row_id"] for r in ds.take_all()}
+        self.assertEqual(set(got), {f"u{i}" for i in range(40)})
+        self.assertTrue(all(got[f"u{i}"] == i for i in range(40)))
+
+    def test_rejects_projection_missing_join_key(self):
+        sch = pa.schema([("url", pa.string()), ("v", pa.int64())])
+        self._create_bucketed("default.pmj1", sch, "url", 8)
+        self._create_bucketed("default.pmj2", sch, "url", 8)
+        with self.assertRaises(ValueError):  # left projection drops the join 
key
+            bucket_join("default.pmj1", "default.pmj2", self.catalog_options,
+                        on="url", left_projection=["v"], 
right_projection=["url"])
+
+    def test_rejects_colliding_columns(self):
+        # Both sides expose a non-key column "v" -> pyarrow join would collide.
+        sch = pa.schema([("url", pa.string()), ("v", pa.int64())])
+        self._create_bucketed("default.col1", sch, "url", 8)
+        self._create_bucketed("default.col2", sch, "url", 8)
+        with self.assertRaises(ValueError):
+            bucket_join("default.col1", "default.col2", self.catalog_options, 
on="url")
+
+    def test_rejects_different_bucket_function(self):
+        # Same bucket-key/count but different bucket function -> same key may 
land in
+        # different buckets, so co-location is not guaranteed.
+        sch = pa.schema([("k", pa.int64())])
+        self._create_bucketed("default.bf_default", sch, "k", 8)
+        self._create_bucketed("default.bf_mod", sch, "k", 8,
+                              extra_opts={"bucket-function.type": "mod"})
+        with self.assertRaises(ValueError):
+            bucket_join("default.bf_default", "default.bf_mod", 
self.catalog_options, on="k")
+
+    def test_rejects_mismatched_key_type(self):
+        # Same bucket-key name but different type hashes differently -> reject.
+        self._create_bucketed("default.ty_str", pa.schema([("k", 
pa.string())]), "k", 8)
+        self._create_bucketed("default.ty_int", pa.schema([("k", 
pa.int64())]), "k", 8)
+        with self.assertRaises(ValueError):
+            bucket_join("default.ty_str", "default.ty_int", 
self.catalog_options, on="k")
+
+    def test_rejects_rescaled_mixed_buckets(self):
+        # Files left under an old bucket count (rescale not yet rewritten) 
would carry a
+        # different total_buckets; the same bucket id must not be treated as 
co-located.
+        import types
+        from pypaimon.read.scanner.file_scanner import FileScanner
+        sch = pa.schema([("url", pa.string())])
+        self._bucketed_table("default.rs_a", sch, "url",
+                             pa.Table.from_pydict({"url": ["u0", "u1"]}, 
schema=sch))
+        self._bucketed_table("default.rs_b", sch, "url",
+                             pa.Table.from_pydict({"url": ["u0"]}, schema=sch))
+        stale = [types.SimpleNamespace(total_buckets=4)]  # a file from a 
4-bucket era
+        with mock.patch.object(FileScanner, "plan_files", return_value=stale):
+            with self.assertRaises(ValueError):
+                bucket_join("default.rs_a", "default.rs_b", 
self.catalog_options, on="url")
+
+    def test_rejects_partitioned_table(self):
+        # Bucket ids are per-partition, so bucket-only grouping would join 
across
+        # partitions; partitioned tables are rejected until (partition, 
bucket) grouping.
+        sch = pa.schema([("url", pa.string()), ("dt", pa.string())])
+        self._create_bucketed("default.part_p", sch, "url", 8, 
partition_keys=["dt"])
+        self._create_bucketed("default.part_np", sch, "url", 8)
+        with self.assertRaises(ValueError):
+            bucket_join("default.part_p", "default.part_np", 
self.catalog_options, on="url")
+
+    def test_rejects_non_inner_join(self):
+        sch = pa.schema([("url", pa.string())])
+        self._create_bucketed("default.ji1", sch, "url", 8)
+        self._create_bucketed("default.ji2", sch, "url", 8)
+        with self.assertRaises(ValueError):
+            bucket_join("default.ji1", "default.ji2", self.catalog_options,
+                        on="url", join_type="left outer")
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to