blob: 398ac2a37cc713576000b9287dd8ad5bbf811c5f [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.
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()