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 0dd0bcc31c [python] Add range_join: shuffle-free Ray join for
range-clustered tables (#8738)
0dd0bcc31c is described below
commit 0dd0bcc31c92fbe796a304692d5a1bf2cb106045
Author: XiaoHongbo <[email protected]>
AuthorDate: Fri Jul 31 10:17:08 2026 +0800
[python] Add range_join: shuffle-free Ray join for range-clustered tables
(#8738)
---
.github/workflows/paimon-python-checks.yml | 3 +-
docs/docs/pypaimon/ray-data.md | 29 ++
paimon-python/pypaimon/ray/__init__.py | 2 +
paimon-python/pypaimon/ray/bucket_join.py | 75 +--
paimon-python/pypaimon/ray/join_common.py | 92 ++++
paimon-python/pypaimon/ray/range_join.py | 492 +++++++++++++++++++
.../pypaimon/tests/ray_range_join_test.py | 546 +++++++++++++++++++++
7 files changed, 1180 insertions(+), 59 deletions(-)
diff --git a/.github/workflows/paimon-python-checks.yml
b/.github/workflows/paimon-python-checks.yml
index b11ae24e3d..d8ff36e5f9 100755
--- a/.github/workflows/paimon-python-checks.yml
+++ b/.github/workflows/paimon-python-checks.yml
@@ -292,7 +292,8 @@ jobs:
python -m pip install --no-cache-dir -q ray==$ray_version
python -c "import ray; print(f'Ray version: {ray.__version__}')"
python -c "from packaging.version import parse; import ray; assert
parse(ray.__version__) == parse('$ray_version'), f'Expected Ray $ray_version,
got {ray.__version__}'"
- python -m pytest pypaimon/tests/ray_data_test.py::RayDataTest -v
--tb=short || {
+ python -m pytest pypaimon/tests/ray_data_test.py::RayDataTest \
+ pypaimon/tests/ray_range_join_test.py::RayRangeJoinTest -v
--tb=short || {
echo "Tests failed for Ray $ray_version"; python -m pip
uninstall -y ray; exit 1;
}
python -m pip uninstall -y ray
diff --git a/docs/docs/pypaimon/ray-data.md b/docs/docs/pypaimon/ray-data.md
index 88c0a7143a..7f56169eb4 100644
--- a/docs/docs/pypaimon/ray-data.md
+++ b/docs/docs/pypaimon/ray-data.md
@@ -397,6 +397,35 @@ ds = bucket_join(
that spreads keys evenly to avoid skewed, memory-heavy tasks.
- Partitioned tables are not supported yet (bucket ids are per-partition).
+## Range Join
+
+`range_join` joins tables clustered by the first join key without a global
+shuffle. Each key range runs in one Ray task.
+
+```python
+from pypaimon.ray import range_join
+
+ds = range_join(
+ left="database_name.incoming_keys",
+ right="database_name.key_rowid",
+ catalog_options={"warehouse": "/path/to/warehouse"},
+ left_on="url",
+ right_on="lookup_url",
+ left_projection=["url"],
+ right_projection=["lookup_url", "row_id"],
+ left_partitions={"dt": "2026-07-30"}, # optional
+ num_ranges=64, # optional
+)
+```
+
+Use `on="url"` when key names match. Multiple keys are supported; the first
+defines ranges. Only inner join is supported.
+
+Manifest/key stats are preferred; Parquet footers are the fallback. Missing
+stats safely reduce parallelism, possibly to one task. Unclustered files may be
+read repeatedly. Float/double and local-time-zone timestamp range keys are not
+supported.
+
## 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 bc91c8da45..03de068249 100644
--- a/paimon-python/pypaimon/ray/__init__.py
+++ b/paimon-python/pypaimon/ray/__init__.py
@@ -17,6 +17,7 @@
from pypaimon.ray.ray_paimon import map_with_blobs, read_paimon, write_paimon
from pypaimon.ray.bucket_join import bucket_join
+from pypaimon.ray.range_join import range_join
from pypaimon.ray.data_evolution_merge_into import (
WhenMatched,
WhenNotMatched,
@@ -35,6 +36,7 @@ __all__ = [
"map_with_blobs",
"write_paimon",
"bucket_join",
+ "range_join",
"merge_into",
"update_by_row_id",
"read_by_row_id",
diff --git a/paimon-python/pypaimon/ray/bucket_join.py
b/paimon-python/pypaimon/ray/bucket_join.py
index 818643ec95..2b377126df 100644
--- a/paimon-python/pypaimon/ray/bucket_join.py
+++ b/paimon-python/pypaimon/ray/bucket_join.py
@@ -21,22 +21,21 @@ Same key -> same bucket on both sides, so each bucket is
read and joined in its
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
+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"]
-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
@@ -46,34 +45,8 @@ def _bucketing(table):
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
+ 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):
@@ -83,24 +56,12 @@ def _plan_splits_by_bucket(table_id, catalog_options,
projection, expected_total
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)
+ # 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
@@ -121,9 +82,7 @@ def _plan_splits_by_bucket(table_id, catalog_options,
projection, expected_total
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)
+ return read_splits(table_id, catalog_options, projection, splits,
schema_id, "bucket_join")
def bucket_join(
diff --git a/paimon-python/pypaimon/ray/join_common.py
b/paimon-python/pypaimon/ray/join_common.py
new file mode 100644
index 0000000000..0892ea9c08
--- /dev/null
+++ b/paimon-python/pypaimon/ray/join_common.py
@@ -0,0 +1,92 @@
+# 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.
+
+"""Shared driver/worker helpers for the co-located Ray joins (bucket_join,
range_join)."""
+
+import threading
+from typing import Dict, List, Optional, Sequence, Union
+
+OnSpec = Union[str, Sequence[str]]
+
+
+def norm_on(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 compares the same
either way.
+ return str(table.field_dict[col].type).replace(" NOT NULL", "")
+
+
+# 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, join_name="join"):
+ 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 {join_name} (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,
join_name="join"):
+ rb = get_table(table_id, catalog_options, schema_id,
join_name).new_read_builder()
+ return rb.with_projection(projection) if projection is not None else rb
+
+
+def read_splits(table_id, catalog_options, projection, splits, schema_id,
+ join_name="join", predicate=None):
+ # Snapshot-independent but schema-dependent -> cache by schema id (in
get_table).
+ rb = read_builder(table_id, catalog_options, projection, schema_id,
join_name)
+ if predicate is not None:
+ rb = rb.with_filter(predicate)
+ return rb.new_read().to_arrow(splits)
+
+
+def pin_latest_snapshot(table) -> Optional[int]:
+ """Pin the table instance to its latest snapshot; returns the snapshot id
or None
+ when the table is empty. Pinning keeps every manifest read of a plan
consistent."""
+ from pypaimon.common.options.core_options import CoreOptions
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ if snapshot is None:
+ return None
+ # 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)
+ return snapshot.id
diff --git a/paimon-python/pypaimon/ray/range_join.py
b/paimon-python/pypaimon/ray/range_join.py
new file mode 100644
index 0000000000..6886d4bfce
--- /dev/null
+++ b/paimon-python/pypaimon/ray/range_join.py
@@ -0,0 +1,492 @@
+# 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.
+
+"""Shuffle-free Ray join for tables clustered by the first join key.
+
+Ranges use manifest/key stats with a Parquet-footer fallback. Missing stats are
+safe: affected splits join every overlapping range and are filtered in memory.
+"""
+
+import logging
+import threading
+from typing import Any, Dict, List, Optional
+
+from pypaimon.ray.join_common import (
+ OnSpec,
+ get_table,
+ key_type,
+ norm_on,
+ pin_latest_snapshot,
+ read_splits,
+)
+
+__all__ = ["range_join"]
+
+_LOG = logging.getLogger(__name__)
+
+_MAX_RANGES = 512
+# Cap total re-read to this many full scans of the read bytes (see
_bounded_ranges).
+_REREAD_BUDGET = 2
+
+
+def _stats_range(stats, field_id, key_type):
+ """Read matching field-id/type bounds from manifest stats."""
+ from pypaimon.schema.data_types import PyarrowFieldParser
+
+ min_row, max_row = stats.min_values, stats.max_values
+ min_fields = getattr(min_row, "fields", []) or []
+ max_fields = getattr(max_row, "fields", []) or []
+ idx = next((i for i, field in enumerate(min_fields) if field.id ==
field_id), None)
+ if idx is None or idx >= len(max_fields) or max_fields[idx].id != field_id:
+ return None
+ try:
+ stored_type = PyarrowFieldParser.from_paimon_type(min_fields[idx].type)
+ max_type = PyarrowFieldParser.from_paimon_type(max_fields[idx].type)
+ if stored_type != key_type or max_type != key_type:
+ return None
+ lo, hi = min_row.get_field(idx), max_row.get_field(idx)
+ except Exception:
+ return None
+ return None if lo is None or hi is None else (lo, hi)
+
+
+def _file_stats_range(file, field_id, key_type):
+ """Prefer key stats, then value stats."""
+ return (_stats_range(file.key_stats, field_id, key_type)
+ or _stats_range(file.value_stats, field_id, key_type))
+
+
+def _file_identity(file):
+ # External paths disambiguate imported files.
+ return file.external_path or file.file_name
+
+
+def _manifest_stats_by_file(table, field_id, key_type, snapshot_id):
+ """Read active-file stats for the pinned snapshot."""
+ from pypaimon.manifest.manifest_file_manager import ManifestFileManager
+ from pypaimon.manifest.manifest_list_manager import ManifestListManager
+
+ try:
+ snapshot = table.snapshot_manager().get_snapshot_by_id(snapshot_id)
+ manifests = ManifestListManager(table).read_all(snapshot)
+ entries = ManifestFileManager(table).read_entries_parallel(
+ manifests, drop_stats=False)
+ except Exception as e:
+ # Stats are optional.
+ _LOG.warning(
+ "range_join: manifest stats read failed (%s); falling back to file
footers", e)
+ return {}
+
+ by_file, ambiguous = {}, set()
+ for entry in entries:
+ file = entry.file
+ rng = _file_stats_range(file, field_id, key_type)
+ if rng is None:
+ continue
+ identity = _file_identity(file)
+ previous = by_file.get(identity)
+ if previous is not None and previous != rng:
+ ambiguous.add(identity)
+ else:
+ by_file[identity] = rng
+ for identity in ambiguous:
+ by_file.pop(identity, None)
+ return by_file
+
+
+def _parquet_col_range(metadata, col):
+ """Min/max of ``col`` across a parquet file's row groups; None when a row
group
+ lacks usable stats for ``col``."""
+ lo, hi = None, None
+ for i in range(metadata.num_row_groups):
+ rg = metadata.row_group(i)
+ stats = None
+ for j in range(rg.num_columns):
+ if rg.column(j).path_in_schema == col:
+ stats = rg.column(j).statistics
+ break
+ if stats is None or not stats.has_min_max:
+ return None
+ lo = stats.min if lo is None else min(lo, stats.min)
+ hi = stats.max if hi is None else max(hi, stats.max)
+ return None if lo is None else (lo, hi)
+
+
+def _footer_col_type(metadata, col):
+ """The arrow type ``col`` is stored as in this parquet file; None if
unavailable."""
+ try:
+ return metadata.schema.to_arrow_schema().field(col).type
+ except Exception:
+ return None
+
+
+def _split_key_range(
+ split, name_for_schema, field_id, key_type, file_io, manifest_stats):
+ """Split bounds from key/manifest stats, then Parquet footers."""
+ import pyarrow.parquet as pq
+ lo, hi = None, None
+ for f in split.files:
+ rng = (_file_stats_range(f, field_id, key_type)
+ or manifest_stats.get(_file_identity(f)))
+ if rng is not None:
+ lo = rng[0] if lo is None else min(lo, rng[0])
+ hi = rng[1] if hi is None else max(hi, rng[1])
+ continue
+ col = name_for_schema(f.schema_id)
+ if col is None:
+ return None, None
+ path = f.external_path if f.external_path else f.file_path
+ if path is None or not path.endswith(".parquet"):
+ return None, None
+ try:
+ stream = file_io.new_input_stream(path)
+ try:
+ metadata = pq.read_metadata(stream)
+ finally:
+ stream.close()
+ except Exception as e:
+ # Footer read can fail (e.g. local-cache streams aren't seekable):
degrade to
+ # unknown, but warn -- the fallback would otherwise be silent.
+ _LOG.warning("range_join: parquet footer read failed for %s (%s);
treating "
+ "its range as unknown", path, e)
+ return None, None
+ if _footer_col_type(metadata, col) != key_type:
+ return None, None
+ rng = _parquet_col_range(metadata, col)
+ if rng is None:
+ return None, None
+ lo = rng[0] if lo is None else min(lo, rng[0])
+ hi = rng[1] if hi is None else max(hi, rng[1])
+ if lo is None:
+ return None, None
+ return lo, hi
+
+
+def _range_stats_trusted(table, splits, range_col):
+ """Whether stored bounds still enclose the read value."""
+ from pypaimon.read.query_auth_split import QueryAuthSplit
+
+ masked = any(isinstance(s, QueryAuthSplit) and s.auth_result.column_masking
+ and range_col in s.auth_result.column_masking for s in splits)
+ # Merge engines may rewrite non-PK values.
+ return not masked and not (
+ table.primary_keys and range_col not in table.primary_keys)
+
+
+def _plan_ranged_splits(table_id, catalog_options, projection, range_col,
partitions=None):
+ """Plan ``(split, min, max)`` entries on the driver."""
+ import os
+ from concurrent.futures import ThreadPoolExecutor
+ from pypaimon.common.predicate_builder import PredicateBuilder
+ from pypaimon.schema.data_types import PyarrowFieldParser
+ table = get_table(table_id, catalog_options, None, "range_join")
+ schema_id = table.table_schema.id
+ snapshot_id = pin_latest_snapshot(table)
+ if snapshot_id is None:
+ return [], schema_id
+ file_io = table.file_io
+ key_type = PyarrowFieldParser.from_paimon_schema(
+ table.table_schema.fields).field(range_col).type
+
+ # Range key's physical name in a file's schema, by field id (rename/swap
safe).
+ # Cached; current-schema files skip the schema load.
+ key_field_id = next(f.id for f in table.table_schema.fields if f.name ==
range_col)
+ name_cache, cache_lock = {schema_id: range_col}, threading.Lock()
+
+ def name_for_schema(sid):
+ with cache_lock:
+ if sid in name_cache:
+ return name_cache[sid]
+ try:
+ fields = table.schema_manager.get_schema(sid).fields
+ name = next((f.name for f in fields if f.id == key_field_id), None)
+ except Exception:
+ name = None
+ with cache_lock:
+ name_cache[sid] = name
+ return name
+
+ rb = table.new_read_builder()
+ if partitions:
+ # Build the partition predicate before projection, so its field list
still has
+ # the partition columns. None means the null partition (is_null, not =
None).
+ pb = rb.new_predicate_builder()
+ rb = rb.with_partition_filter(PredicateBuilder.and_predicates(
+ [pb.is_null(c) if v is None else pb.equal(c, v)
+ for c, v in partitions.items()]))
+ if projection is not None:
+ rb = rb.with_projection(projection)
+ splits = list(rb.new_scan().plan().splits())
+ # Merges or masking can move values beyond stored bounds.
+ if not _range_stats_trusted(table, splits, range_col):
+ return [(s, None, None) for s in splits], schema_id
+ manifest_stats = _manifest_stats_by_file(
+ table, key_field_id, key_type, snapshot_id)
+ workers = min(16, (os.cpu_count() or 4) * 4, len(splits) or 1)
+ with ThreadPoolExecutor(max_workers=workers) as pool:
+ bounds = pool.map(
+ lambda s: _split_key_range(
+ s, name_for_schema, key_field_id, key_type, file_io,
manifest_stats),
+ splits)
+ return [(s, lo, hi) for s, (lo, hi) in zip(splits, bounds)], schema_id
+
+
+def _cut_points(ranged_sides, num_ranges):
+ """Pick ``num_ranges - 1`` cut values from row-count-weighted file
boundaries."""
+ points = []
+ for ranged in ranged_sides:
+ for split, lo, hi in ranged:
+ if lo is None:
+ continue
+ rows = sum(f.row_count for f in split.files)
+ points.append((lo, rows / 2.0))
+ points.append((hi, rows / 2.0))
+ if not points:
+ return []
+ points.sort(key=lambda p: p[0])
+ total = sum(w for _, w in points)
+ cuts, acc, k = [], 0.0, 1
+ for value, weight in points:
+ acc += weight
+ if k >= num_ranges:
+ break
+ if acc >= total * k / num_ranges:
+ if not cuts or value > cuts[-1]: # strictly increasing
+ cuts.append(value)
+ k += 1
+ return cuts
+
+
+def _split_rows(split):
+ return sum(f.row_count for f in split.files)
+
+
+def _split_bytes(split):
+ # Re-read cost is bytes, not rows: a wide-row split is cheap by rows,
costly by I/O.
+ return sum(f.file_size for f in split.files)
+
+
+def _total_reads(l_ranged, r_ranged, ranges):
+ """Bytes physically read = each split's bytes times the ranges it overlaps
(each range
+ reads the whole split and clips). Counts unknown-stats and wide known
splits alike."""
+ reads = 0
+ for ranged in (l_ranged, r_ranged):
+ for split, lo, hi in ranged:
+ spans = sum(1 for r_lo, r_hi in ranges if _overlaps(lo, hi, r_lo,
r_hi))
+ reads += _split_bytes(split) * spans
+ return reads
+
+
+def _bounded_ranges(l_ranged, r_ranged, num_ranges):
+ """Cut into ``num_ranges`` ranges, halving until total re-read <=
_REREAD_BUDGET full
+ scans of bytes, so poorly clustered input can't cost far more than one
scan."""
+ total_bytes = sum(_split_bytes(s)
+ for ranged in (l_ranged, r_ranged) for s, _, _ in ranged)
+ budget = _REREAD_BUDGET * max(1, total_bytes)
+ while True:
+ ranges = _ranges_from_cuts(_cut_points((l_ranged, r_ranged),
num_ranges))
+ if num_ranges <= 1 or _total_reads(l_ranged, r_ranged, ranges) <=
budget:
+ return ranges
+ num_ranges = max(1, num_ranges // 2)
+
+
+def _ranges_from_cuts(cuts):
+ # Half-open [lo, hi); None = unbounded end.
+ bounds = [None] + cuts + [None]
+ return [(bounds[i], bounds[i + 1]) for i in range(len(bounds) - 1)]
+
+
+def _overlaps(lo, hi, r_lo, r_hi):
+ if lo is None: # unknown split range: belongs to every range
+ return True
+ return (r_lo is None or hi >= r_lo) and (r_hi is None or lo < r_hi)
+
+
+def _restrict_to_range(arrow_table, col, lo, hi):
+ """Keep rows with ``lo <= col < hi``. Null keys are always dropped (an
inner
+ join never matches them), which also keeps the result independent of
num_ranges."""
+ import pyarrow.compute as pc
+ mask = pc.is_valid(arrow_table[col])
+ if lo is not None:
+ mask = pc.and_(mask, pc.greater_equal(arrow_table[col], lo))
+ if hi is not None:
+ mask = pc.and_(mask, pc.less(arrow_table[col], hi))
+ return arrow_table.filter(mask)
+
+
+def range_join(
+ left: str,
+ right: str,
+ catalog_options: Dict[str, str],
+ *,
+ on: Optional[OnSpec] = None,
+ left_on: Optional[OnSpec] = None,
+ right_on: Optional[OnSpec] = None,
+ num_ranges: Optional[int] = None,
+ left_projection: Optional[List[str]] = None,
+ right_projection: Optional[List[str]] = None,
+ left_partitions: Optional[Dict[str, Any]] = None,
+ right_partitions: Optional[Dict[str, Any]] = None,
+ join_type: str = "inner",
+ ray_remote_args: Optional[Dict[str, Any]] = None,
+) -> "ray.data.Dataset":
+ """Join two tables clustered by the first join key with no global shuffle.
+
+ ``on`` when both sides use the same column names, or
``left_on``/``right_on``
+ when they differ (positionally paired). The first pair is the range key
used to
+ cut the key space. ``left_partitions``/``right_partitions`` ({column:
value} dicts
+ on partition columns) prune each side to those partitions first. Sides
must not
+ share column names other than ``on`` keys. Returns a ``ray.data.Dataset``.
+ """
+ import ray
+
+ if not hasattr(ray.data, "from_arrow_refs"):
+ raise RuntimeError(
+ "range_join needs a Ray version with ray.data.from_arrow_refs; "
+ f"installed ray is {ray.__version__}.")
+
+ if (on is None) == (left_on is None and right_on is None):
+ raise ValueError("range_join requires exactly one of on= or
left_on=/right_on=.")
+ if on is not None:
+ lkeys = rkeys = norm_on(on)
+ else:
+ if left_on is None or right_on is None:
+ raise ValueError("range_join requires both left_on= and
right_on=.")
+ lkeys, rkeys = norm_on(left_on), norm_on(right_on)
+ if len(lkeys) != len(rkeys) or not lkeys:
+ raise ValueError(
+ f"range_join join keys must pair up non-empty; got
left_on={lkeys}, right_on={rkeys}.")
+ if join_type != "inner":
+ # Outer joins would need every unmatched row emitted exactly once
across
+ # ranges plus null-key handling; only inner is supported for now.
+ raise ValueError(f"range_join currently supports only
join_type='inner'; got {join_type!r}.")
+
+ ltable = get_table(left, catalog_options, None, "range_join")
+ rtable = get_table(right, catalog_options, None, "range_join")
+
+ # Partition filters must name partition columns, else they'd be silently
ignored.
+ for name, tbl, parts in (("left_partitions", ltable, left_partitions),
+ ("right_partitions", rtable, right_partitions)):
+ bad = sorted(set(parts) - set(tbl.partition_keys)) if parts else []
+ if bad:
+ raise ValueError(
+ f"range_join {name} keys {bad} are not partition columns; "
+ f"partition columns are {list(tbl.partition_keys)}.")
+
+ missing = [c for c in lkeys if c not in ltable.field_dict] \
+ + [c for c in rkeys if c not in rtable.field_dict]
+ if missing:
+ raise ValueError(f"range_join keys not found in table schema:
{missing}.")
+ type_mismatch = [
+ (lc, rc, key_type(ltable, lc), key_type(rtable, rc))
+ for lc, rc in zip(lkeys, rkeys)
+ if key_type(ltable, lc) != key_type(rtable, rc)
+ ]
+ if type_mismatch:
+ raise ValueError(
+ "range_join key columns must have the same type on both sides; "
+ f"mismatched (left, right, left type, right type):
{type_mismatch}.")
+
+ # Reject unsupported key types up front (not inside a worker as
ArrowInvalid). Every
+ # join key must be hashable; nested (ARRAY<>/MAP<>/ROW<>/...) and VARIANT
are not.
+ for c in lkeys:
+ t = key_type(ltable, c).upper()
+ if "<" in t or t.startswith("VARIANT"):
+ raise ValueError(
+ f"range_join join key {c!r} must not be a nested/complex type;
got {t}.")
+ # The range key (first pair) additionally must be range-partitionable.
+ range_key_type = key_type(ltable, lkeys[0]).upper()
+ reason = None
+ if range_key_type.startswith(("FLOAT", "DOUBLE")):
+ # NaN falls out of every range while the hash join still matches it ->
drops rows.
+ reason = "FLOAT/DOUBLE"
+ elif "LOCAL TIME ZONE" in range_key_type or "TIMESTAMP_LTZ" in
range_key_type:
+ # Footer stats decode to naive datetimes; a tz-aware column can't
compare to them.
+ reason = "TIMESTAMP WITH LOCAL TIME ZONE"
+ if reason:
+ raise ValueError(
+ f"range_join range key {lkeys[0]!r} must not be {reason}; "
+ "use an integer/string/date/timestamp key.")
+
+ # The join keys must survive projection, or the local join has no key.
+ if left_projection is not None and not set(lkeys) <= set(left_projection):
+ raise ValueError(
+ f"left_projection must include the join keys {lkeys}; got
{left_projection}.")
+ if right_projection is not None and not set(rkeys) <=
set(right_projection):
+ raise ValueError(
+ f"right_projection must include the join keys {rkeys}; got
{right_projection}.")
+ # pyarrow drops the right keys (coalesced into the left), so the output
keeps the LEFT
+ # key names. A right non-key column sharing a left column name collides ->
reject it.
+ 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(rkeys)))
+ if collisions:
+ raise ValueError(
+ f"range_join output columns collide: {collisions}. The output
keeps the left "
+ "key names and the right non-key columns; project or rename the
overlap away.")
+
+ l_range_col, r_range_col = lkeys[0], rkeys[0]
+ l_ranged, l_schema_id = _plan_ranged_splits(
+ left, catalog_options, left_projection, l_range_col, left_partitions)
+ r_ranged, r_schema_id = _plan_ranged_splits(
+ right, catalog_options, right_projection, r_range_col,
right_partitions)
+
+ def _empty():
+ empty = read_splits(
+ left, catalog_options, left_projection, [], l_schema_id,
"range_join").join(
+ read_splits(right, catalog_options, right_projection, [],
r_schema_id, "range_join"),
+ keys=lkeys, right_keys=rkeys, join_type=join_type)
+ return ray.data.from_arrow(empty)
+
+ if not l_ranged or not r_ranged: # inner join: one empty side, empty
result
+ return _empty()
+
+ if num_ranges is None:
+ num_ranges = max(len(l_ranged), len(r_ranged))
+ elif not isinstance(num_ranges, int) or num_ranges < 1:
+ raise ValueError(f"num_ranges must be an int >= 1; got
{num_ranges!r}.")
+ num_ranges = max(1, min(_MAX_RANGES, num_ranges)) # cap tasks even when
explicit
+ # Reduce ranges until total re-read stays bounded -- see _bounded_ranges.
+ ranges = _bounded_ranges(l_ranged, r_ranged, num_ranges)
+
+ def _join_range(left_splits, right_splits, lo, hi):
+ # No predicate pushdown: the range key may be schema-evolved (e.g. a
file stored
+ # as INT read as STRING), which the reader can't compare against a
new-type bound.
+ # The in-memory clip below does the exact, evolution-safe filtering.
+ lt = _restrict_to_range(
+ read_splits(left, catalog_options, left_projection, left_splits,
+ l_schema_id, "range_join"),
+ l_range_col, lo, hi)
+ rt = _restrict_to_range(
+ read_splits(right, catalog_options, right_projection, right_splits,
+ r_schema_id, "range_join"),
+ r_range_col, lo, hi)
+ return lt.join(rt, keys=lkeys, right_keys=rkeys, join_type=join_type)
+
+ # ``@ray.remote()`` (empty parens) is rejected by Ray, so wrap
conditionally.
+ remote_fn = ray.remote(**ray_remote_args)(_join_range) if ray_remote_args
else ray.remote(_join_range)
+ refs = []
+ for r_lo, r_hi in ranges:
+ ls = [s for s, lo, hi in l_ranged if _overlaps(lo, hi, r_lo, r_hi)]
+ rs = [s for s, lo, hi in r_ranged if _overlaps(lo, hi, r_lo, r_hi)]
+ if not ls or not rs: # inner join: a one-sided range can't match
+ continue
+ refs.append(remote_fn.remote(ls, rs, r_lo, r_hi))
+ if not refs:
+ return _empty()
+ # Keep each range's result as a distributed object ref -- never pulled
into the driver.
+ return ray.data.from_arrow_refs(refs)
diff --git a/paimon-python/pypaimon/tests/ray_range_join_test.py
b/paimon-python/pypaimon/tests/ray_range_join_test.py
new file mode 100644
index 0000000000..3696bf851f
--- /dev/null
+++ b/paimon-python/pypaimon/tests/ray_range_join_test.py
@@ -0,0 +1,546 @@
+# 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 collections
+import datetime
+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 range_join
+
+rjmod = importlib.import_module("pypaimon.ray.range_join")
+
+
+class RayRangeJoinTest(unittest.TestCase):
+ """Range-aligned join must equal a global inner join, cutting the key
space from
+ per-file min/max stats so each range is read/joined in its own task (no
shuffle)."""
+
+ @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 _table(self, name, schema, commits, primary_keys=None, options=None):
+ """Create a table and write each arrow table in ``commits`` as its own
commit,
+ so the manifest holds several data files with distinct key ranges."""
+ self.catalog.create_table(
+ name,
+ Schema.from_pyarrow_schema(schema, primary_keys=primary_keys,
options=options),
+ False)
+ t = self.catalog.get_table(name)
+ for data in commits:
+ 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 test_range_join_matches_global_join(self):
+ loc = pa.schema([("k", pa.int64()), ("row_id", pa.int64())])
+ ins = pa.schema([("k", pa.int64())])
+ # locator: k in 0..599 spread across three files with disjoint key
ranges.
+ self._table("default.rj_loc", loc, [
+ pa.Table.from_pydict({"k": list(range(0, 200)),
+ "row_id": list(range(0, 200))}, schema=loc),
+ pa.Table.from_pydict({"k": list(range(200, 400)),
+ "row_id": list(range(200, 400))},
schema=loc),
+ pa.Table.from_pydict({"k": list(range(400, 600)),
+ "row_id": list(range(400, 600))},
schema=loc),
+ ])
+ self._table("default.rj_in", ins, [
+ pa.Table.from_pydict({"k": list(range(0, 250))}, schema=ins),
+ ])
+ ds = range_join(
+ "default.rj_in", "default.rj_loc", self.catalog_options,
+ on="k", left_projection=["k"], right_projection=["k", "row_id"],
num_ranges=4)
+ got = {r["k"]: r["row_id"] for r in ds.take_all()}
+ self.assertEqual(set(got), set(range(250)))
+ self.assertTrue(all(got[i] == i for i in range(250)))
+
+ def test_fan_out_one_key_many_rows(self):
+ loc = pa.schema([("k", pa.int64()), ("row_id", pa.int64())])
+ ins = pa.schema([("k", pa.int64())])
+ self._table("default.rj_fan_loc", loc, [
+ pa.Table.from_pydict({"k": [5, 5, 7], "row_id": [0, 1, 2]},
schema=loc)])
+ self._table("default.rj_fan_in", ins, [
+ pa.Table.from_pydict({"k": [5]}, schema=ins)])
+ ds = range_join(
+ "default.rj_fan_in", "default.rj_fan_loc", self.catalog_options,
+ on="k", left_projection=["k"], right_projection=["k", "row_id"])
+ self.assertEqual(sorted(r["row_id"] for r in ds.take_all()), [0, 1])
+
+ def test_left_on_right_on_different_names(self):
+ right = pa.schema([("rid", pa.int64()), ("val", pa.string())])
+ left = pa.schema([("lid", pa.int64())])
+ self._table("default.rj_lr_right", right, [
+ pa.Table.from_pydict({"rid": list(range(100)),
+ "val": [f"v{i}" for i in range(100)]},
schema=right)])
+ self._table("default.rj_lr_left", left, [
+ pa.Table.from_pydict({"lid": list(range(30))}, schema=left)])
+ ds = range_join(
+ "default.rj_lr_left", "default.rj_lr_right", self.catalog_options,
+ left_on="lid", right_on="rid", num_ranges=3)
+ # Output keeps the left key name (pyarrow coalesces the right key into
it).
+ got = {r["lid"]: r["val"] for r in ds.take_all()}
+ self.assertEqual(got, {i: f"v{i}" for i in range(30)})
+
+ def test_num_ranges_one_is_correct(self):
+ # A single range degenerates to one local join and must still be exact.
+ loc = pa.schema([("k", pa.int64()), ("row_id", pa.int64())])
+ ins = pa.schema([("k", pa.int64())])
+ self._table("default.rj_one_loc", loc, [
+ pa.Table.from_pydict({"k": list(range(50)),
+ "row_id": list(range(50))}, schema=loc)])
+ self._table("default.rj_one_in", ins, [
+ pa.Table.from_pydict({"k": list(range(20))}, schema=ins)])
+ ds = range_join(
+ "default.rj_one_in", "default.rj_one_loc", self.catalog_options,
+ on="k", left_projection=["k"], right_projection=["k", "row_id"],
num_ranges=1)
+ got = {r["k"]: r["row_id"] for r in ds.take_all()}
+ self.assertEqual(got, {i: i for i in range(20)})
+
+ def test_dispatches_multiple_range_tasks(self):
+ # No global shuffle: several disjoint-range files produce more than
one task.
+ loc = pa.schema([("k", pa.int64()), ("row_id", pa.int64())])
+ ins = pa.schema([("k", pa.int64())])
+ self._table("default.rj_disp_loc", loc, [
+ pa.Table.from_pydict({"k": list(range(0, 300)),
+ "row_id": list(range(0, 300))}, schema=loc),
+ pa.Table.from_pydict({"k": list(range(300, 600)),
+ "row_id": list(range(300, 600))},
schema=loc),
+ ])
+ self._table("default.rj_disp_in", ins, [
+ pa.Table.from_pydict({"k": list(range(0, 600))}, schema=ins)])
+
+ 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 = range_join(
+ "default.rj_disp_in", "default.rj_disp_loc",
self.catalog_options,
+ on="k", left_projection=["k"], right_projection=["k",
"row_id"], num_ranges=4)
+ ds.take_all()
+ self.assertGreater(captured["n"], 1)
+
+ def test_rejects_shared_non_key_column(self):
+ loc = pa.schema([("k", pa.int64()), ("v", pa.int64())])
+ ins = pa.schema([("k", pa.int64()), ("v", pa.int64())])
+ self._table("default.rj_col_loc", loc, [
+ pa.Table.from_pydict({"k": [1], "v": [1]}, schema=loc)])
+ self._table("default.rj_col_in", ins, [
+ pa.Table.from_pydict({"k": [1], "v": [2]}, schema=ins)])
+ with self.assertRaisesRegex(ValueError, "collide"):
+ range_join("default.rj_col_in", "default.rj_col_loc",
self.catalog_options, on="k")
+
+ def test_rejects_key_type_mismatch(self):
+ loc = pa.schema([("k", pa.int32()), ("row_id", pa.int64())])
+ ins = pa.schema([("k", pa.int64())])
+ self._table("default.rj_ty_loc", loc, [
+ pa.Table.from_pydict({"k": pa.array([1], pa.int32()), "row_id":
[1]}, schema=loc)])
+ self._table("default.rj_ty_in", ins, [
+ pa.Table.from_pydict({"k": [1]}, schema=ins)])
+ with self.assertRaisesRegex(ValueError, "same type"):
+ range_join("default.rj_ty_in", "default.rj_ty_loc",
self.catalog_options, on="k")
+
+ def test_partition_filter_and_partitioned_table(self):
+ # range_join works on partitioned tables (unlike bucket_join);
left_partitions
+ # prunes to the requested partition before planning.
+ loc = pa.schema([("p", pa.string()), ("k", pa.int64())])
+ self.catalog.create_table(
+ "default.rj_pf_l",
+ Schema.from_pyarrow_schema(loc, partition_keys=["p"]), False)
+ t = self.catalog.get_table("default.rj_pf_l")
+ wb = t.new_batch_write_builder()
+ w = wb.new_write()
+ w.write_arrow(pa.Table.from_pydict(
+ {"p": ["a", "a", "b", "b"], "k": [1, 2, 3, 4]}, schema=loc))
+ wb.new_commit().commit(w.prepare_commit())
+ w.close()
+ self._table("default.rj_pf_r", pa.schema([("k2", pa.int64()), ("val",
pa.string())]), [
+ pa.Table.from_pydict({"k2": [1, 2, 3, 4], "val": ["v1", "v2",
"v3", "v4"]},
+ schema=pa.schema([("k2", pa.int64()), ("val",
pa.string())]))])
+
+ ds = range_join("default.rj_pf_l", "default.rj_pf_r",
self.catalog_options,
+ left_on="k", right_on="k2", left_projection=["k"],
+ right_projection=["k2", "val"], left_partitions={"p":
"a"}, num_ranges=2)
+ self.assertEqual(sorted((r["k"], r["val"]) for r in ds.take_all()),
+ [(1, "v1"), (2, "v2")])
+ # Whole partitioned table joins fine when unfiltered.
+ ds = range_join("default.rj_pf_l", "default.rj_pf_r",
self.catalog_options,
+ left_on="k", right_on="k2", left_projection=["k"],
+ right_projection=["k2", "val"], num_ranges=2)
+ self.assertEqual(sorted(r["k"] for r in ds.take_all()), [1, 2, 3, 4])
+
+ def test_many_to_many_matches_global_join(self):
+ loc = pa.schema([("k", pa.int64()), ("rid", pa.int64())])
+ ins = pa.schema([("k", pa.int64())])
+ self._table("default.rj_mm_loc", loc, [
+ pa.Table.from_pydict({"k": [1, 1, 2], "rid": [10, 11, 20]},
schema=loc)])
+ self._table("default.rj_mm_in", ins, [
+ pa.Table.from_pydict({"k": [1, 1, 2]}, schema=ins)])
+ # key 1: 2 left x 2 right = 4 rows; key 2: 1 x 1 = 1 row.
+ for num_ranges in (1, 3):
+ ds = range_join("default.rj_mm_in", "default.rj_mm_loc",
self.catalog_options,
+ on="k", left_projection=["k"],
right_projection=["k", "rid"],
+ num_ranges=num_ranges)
+ got = sorted(r["rid"] for r in ds.take_all())
+ self.assertEqual(got, [10, 10, 11, 11, 20])
+
+ def test_rejects_bad_on_spec(self):
+ with self.assertRaisesRegex(ValueError, "exactly one of"):
+ range_join("a", "b", self.catalog_options) # neither on nor
left_on/right_on
+
+ def test_rejects_float_range_key(self):
+ schema = pa.schema([("k", pa.float64()), ("v", pa.int64())])
+ self._table("default.rj_float_a", schema, [
+ pa.Table.from_pydict({"k": [1.0], "v": [1]}, schema=schema)])
+ self._table("default.rj_float_b", schema, [
+ pa.Table.from_pydict({"k": [1.0], "v": [2]}, schema=schema)])
+ with self.assertRaisesRegex(ValueError, "FLOAT/DOUBLE"):
+ range_join("default.rj_float_a", "default.rj_float_b",
self.catalog_options,
+ on="k", left_projection=["k"], right_projection=["k"])
+
+ def test_rejects_left_key_vs_right_column_collision(self):
+ left = pa.schema([("lid", pa.int64()), ("x", pa.int64())])
+ right = pa.schema([("rid", pa.int64()), ("lid", pa.int64())])
+ self._table("default.rj_xn_left", left, [
+ pa.Table.from_pydict({"lid": [1], "x": [1]}, schema=left)])
+ self._table("default.rj_xn_right", right, [
+ pa.Table.from_pydict({"rid": [1], "lid": [9]}, schema=right)])
+ # Left key 'lid' collides with the right non-key column 'lid' in the
output.
+ with self.assertRaisesRegex(ValueError, "collide"):
+ range_join("default.rj_xn_left", "default.rj_xn_right",
self.catalog_options,
+ left_on="lid", right_on="rid")
+
+ def test_date_to_timestamp_schema_evolution(self):
+ # A DATE->TIMESTAMP evolved key yields date footers in old files and
datetime in
+ # new ones; the planner must coerce both to the key type, not compare
them raw.
+ from pypaimon.schema.data_types import AtomicType
+ from pypaimon.schema.schema_change import SchemaChange
+
+ a_date = pa.schema([("k", pa.date32())])
+ self.catalog.create_table(
+ "default.rj_ev_a", Schema.from_pyarrow_schema(a_date), False)
+ t = self.catalog.get_table("default.rj_ev_a")
+ wb = t.new_batch_write_builder()
+ w = wb.new_write()
+ w.write_arrow(pa.Table.from_pydict(
+ {"k": [datetime.date(2020, 1, 1), datetime.date(2020, 1, 2)]},
schema=a_date))
+ wb.new_commit().commit(w.prepare_commit())
+ w.close()
+ self.catalog.alter_table(
+ "default.rj_ev_a",
+ [SchemaChange.update_column_type("k",
AtomicType("TIMESTAMP(6)"))], False)
+ t = self.catalog.get_table("default.rj_ev_a")
+ a_ts = pa.schema([("k", pa.timestamp("us"))])
+ wb = t.new_batch_write_builder()
+ w = wb.new_write()
+ w.write_arrow(pa.Table.from_pydict(
+ {"k": [datetime.datetime(2020, 6, 1), datetime.datetime(2020, 6,
2)]}, schema=a_ts))
+ wb.new_commit().commit(w.prepare_commit())
+ w.close()
+
+ b = pa.schema([("bk", pa.timestamp("us")), ("val", pa.string())])
+ self.catalog.create_table("default.rj_ev_b",
Schema.from_pyarrow_schema(b), False)
+ t = self.catalog.get_table("default.rj_ev_b")
+ wb = t.new_batch_write_builder()
+ w = wb.new_write()
+ w.write_arrow(pa.Table.from_pydict(
+ {"bk": [datetime.datetime(2020, 1, 1), datetime.datetime(2020, 6,
1)],
+ "val": ["jan1", "jun1"]}, schema=b))
+ wb.new_commit().commit(w.prepare_commit())
+ w.close()
+
+ ds = range_join("default.rj_ev_a", "default.rj_ev_b",
self.catalog_options,
+ left_on="k", right_on="bk", num_ranges=3)
+ got = sorted((str(r["k"]), r["val"]) for r in ds.take_all())
+ self.assertEqual(got, [("2020-01-01 00:00:00", "jan1"),
+ ("2020-06-01 00:00:00", "jun1")])
+
+ def test_int_to_string_schema_evolution_no_dropped_rows(self):
+ # INT->STRING isn't order-preserving ('10' < '2'), so an old INT
file's footer
+ # bounds are invalid under the new string order. Such files must be
treated as
+ # unknown (join every range), not pruned, or rows are silently dropped.
+ from pypaimon.schema.data_types import AtomicType
+ from pypaimon.schema.schema_change import SchemaChange
+
+ a_int = pa.schema([("k", pa.int32())])
+ self.catalog.create_table(
+ "default.rj_is_a", Schema.from_pyarrow_schema(a_int), False)
+ t = self.catalog.get_table("default.rj_is_a")
+ wb = t.new_batch_write_builder()
+ w = wb.new_write()
+ # int order 5<42<100, but as strings '100'<'42'<'5'.
+ w.write_arrow(pa.Table.from_pydict({"k": [5, 42, 100]}, schema=a_int))
+ wb.new_commit().commit(w.prepare_commit())
+ w.close()
+ self.catalog.alter_table(
+ "default.rj_is_a",
+ [SchemaChange.update_column_type("k", AtomicType("STRING"))],
False)
+
+ b = pa.schema([("bk", pa.string()), ("val", pa.string())])
+ self.catalog.create_table("default.rj_is_b",
Schema.from_pyarrow_schema(b), False)
+ t = self.catalog.get_table("default.rj_is_b")
+ wb = t.new_batch_write_builder()
+ w = wb.new_write()
+ w.write_arrow(pa.Table.from_pydict(
+ {"bk": ["5", "42", "100"], "val": ["v5", "v42", "v100"]},
schema=b))
+ wb.new_commit().commit(w.prepare_commit())
+ w.close()
+
+ for num_ranges in (1, 3):
+ ds = range_join("default.rj_is_a", "default.rj_is_b",
self.catalog_options,
+ left_on="k", right_on="bk", num_ranges=num_ranges)
+ got = sorted((r["k"], r["val"]) for r in ds.take_all())
+ self.assertEqual(got, [("100", "v100"), ("42", "v42"), ("5",
"v5")])
+
+ def test_reread_budget_bounds_wide_and_unknown_splits(self):
+ Split = collections.namedtuple("Split", "files")
+ File = collections.namedtuple("File", "row_count file_size")
+
+ def rng(lo, hi, rows=100, size=100):
+ return (Split([File(rows, size)]), lo, hi)
+
+ # _total_reads = bytes x ranges a split overlaps.
+ self.assertEqual(rjmod._total_reads([rng(0, 10)], [], [(None, 5), (5,
None)]), 200)
+ # Budget is bytes, not rows: a wide split (few rows, large files)
counts its bytes.
+ wide_row = [rng(0, 10, rows=1, size=1000)]
+ self.assertEqual(rjmod._total_reads(wide_row, [], [(None, 5), (5,
None)]), 2000)
+ # All-unknown collapses to a single range.
+ unknown = [(Split([File(100, 100)]), None, None)]
+ self.assertEqual(len(rjmod._bounded_ranges(unknown, unknown, 8)), 1)
+ # Wide known splits (each overlaps many ranges) are bounded by the
budget.
+ wide = [rng(0, 100), rng(0, 100), rng(0, 100), rng(0, 100)]
+ ranges = rjmod._bounded_ranges(wide, wide, 16)
+ budget = rjmod._REREAD_BUDGET * (8 * 100)
+ self.assertTrue(len(ranges) == 1
+ or rjmod._total_reads(wide, wide, ranges) <= budget)
+ # Clustered (disjoint) splits keep at least as much parallelism as
wide ones.
+ clustered = [rng(0, 9), rng(10, 19), rng(20, 29), rng(30, 39)]
+ self.assertGreaterEqual(len(rjmod._bounded_ranges(clustered,
clustered, 4)),
+ len(rjmod._bounded_ranges(wide, wide, 4)))
+
+ def test_split_key_range_reads_stats(self):
+ # With default metadata.stats-mode=none, the planner falls back to the
footer.
+ loc = pa.schema([("k", pa.int64()), ("row_id", pa.int64())])
+ self._table("default.rj_stats", loc, [
+ pa.Table.from_pydict({"k": [10, 20, 15], "row_id": [1, 2, 3]},
schema=loc)])
+ ranged, _ = rjmod._plan_ranged_splits(
+ "default.rj_stats", self.catalog_options, None, "k")
+ self.assertTrue(ranged)
+ los = [lo for _, lo, _ in ranged if lo is not None]
+ his = [hi for _, _, hi in ranged if hi is not None]
+ self.assertEqual(min(los), 10)
+ self.assertEqual(max(his), 20)
+
+ def test_manifest_stats_avoid_footer_reads(self):
+ schema = pa.schema([("k", pa.int64()), ("v", pa.string())])
+ self._table("default.rj_manifest", schema, [
+ pa.Table.from_pydict({"k": [10, 20, 15], "v": ["a", "b", "c"]},
schema=schema)
+ ], options={"metadata.stats-mode": "full"})
+
+ with mock.patch("pyarrow.parquet.read_metadata",
+ side_effect=AssertionError("footer should not be
read")):
+ ranged, _ = rjmod._plan_ranged_splits(
+ "default.rj_manifest", self.catalog_options, None, "k")
+ self.assertEqual([(lo, hi) for _, lo, hi in ranged], [(10, 20)])
+
+ def test_key_stats_avoid_footer_reads(self):
+ schema = pa.schema([("k", pa.int64()), ("v", pa.string())])
+ self._table("default.rj_key_stats", schema, [
+ pa.Table.from_pydict({"k": [10, 20, 15], "v": ["a", "b", "c"]},
schema=schema)
+ ], primary_keys=["k"], options={"bucket": "1"})
+
+ with mock.patch("pyarrow.parquet.read_metadata",
+ side_effect=AssertionError("footer should not be
read")):
+ ranged, _ = rjmod._plan_ranged_splits(
+ "default.rj_key_stats", self.catalog_options, None, "k")
+ self.assertEqual([(lo, hi) for _, lo, hi in ranged], [(10, 20)])
+
+ def test_manifest_stats_follow_field_id_after_rename(self):
+ from pypaimon.schema.schema_change import SchemaChange
+
+ schema = pa.schema([("k", pa.int64()), ("v", pa.string())])
+ name = "default.rj_manifest_rename"
+ self._table(name, schema, [
+ pa.Table.from_pydict({"k": [3, 9, 6], "v": ["a", "b", "c"]},
schema=schema)
+ ], options={"metadata.stats-mode": "full"})
+ self.catalog.alter_table(
+ name, [SchemaChange.rename_column("k", "renamed")], False)
+
+ with mock.patch("pyarrow.parquet.read_metadata",
+ side_effect=AssertionError("footer should not be
read")):
+ ranged, _ = rjmod._plan_ranged_splits(
+ name, self.catalog_options, None, "renamed")
+ self.assertEqual([(lo, hi) for _, lo, hi in ranged], [(3, 9)])
+
+ def test_footer_failure_degrades_to_unknown(self):
+ schema = pa.schema([("k", pa.int64())])
+ self._table("default.rj_footer_failure", schema, [
+ pa.Table.from_pydict({"k": [1, 2, 3]}, schema=schema)])
+
+ with self.assertLogs(rjmod._LOG, level="WARNING"):
+ with mock.patch("pyarrow.parquet.read_metadata",
+ side_effect=OSError("not seekable")):
+ ranged, _ = rjmod._plan_ranged_splits(
+ "default.rj_footer_failure", self.catalog_options, None,
"k")
+ self.assertTrue(ranged)
+ self.assertTrue(all(lo is None and hi is None for _, lo, hi in ranged))
+
+ def test_stats_mode_none_still_correct(self):
+ # metadata.stats-mode=none only drops manifest stats; the parquet
footer still
+ # carries min/max (range_join's actual source), so ranges still work.
The
+ # unknown-split fallback itself is covered by the planning-logic tests.
+ no_stats = {"metadata.stats-mode": "none"}
+ loc = pa.schema([("k", pa.int64()), ("row_id", pa.int64())])
+ ins = pa.schema([("k", pa.int64())])
+ self._table("default.rj_ns_loc", loc, [
+ pa.Table.from_pydict({"k": list(range(0, 100)),
+ "row_id": list(range(0, 100))}, schema=loc),
+ pa.Table.from_pydict({"k": list(range(100, 200)),
+ "row_id": list(range(100, 200))},
schema=loc),
+ ], options=no_stats)
+ self._table("default.rj_ns_in", ins, [
+ pa.Table.from_pydict({"k": list(range(50, 150))}, schema=ins)],
+ options=no_stats)
+ ds = range_join(
+ "default.rj_ns_in", "default.rj_ns_loc", self.catalog_options,
+ on="k", left_projection=["k"], right_projection=["k", "row_id"],
num_ranges=4)
+ got = sorted((r["k"], r["row_id"]) for r in ds.take_all())
+ self.assertEqual(got, [(i, i) for i in range(50, 150)])
+
+ def test_null_keys_dropped_independent_of_num_ranges(self):
+ loc = pa.schema([("k", pa.int64()), ("row_id", pa.int64())])
+ ins = pa.schema([("k", pa.int64())])
+ self._table("default.rj_null_loc", loc, [
+ pa.Table.from_pydict({"k": [1, 2, None, 3], "row_id": [1, 2, 99,
3]}, schema=loc)])
+ self._table("default.rj_null_in", ins, [
+ pa.Table.from_pydict({"k": [1, None, 3, None]}, schema=ins)])
+ expected = [(1, 1), (3, 3)] # null never matches; no duplicates
+ for num_ranges in (1, 5):
+ ds = range_join(
+ "default.rj_null_in", "default.rj_null_loc",
self.catalog_options,
+ on="k", left_projection=["k"], right_projection=["k",
"row_id"],
+ num_ranges=num_ranges)
+ got = sorted((r["k"], r["row_id"]) for r in ds.take_all())
+ self.assertEqual(got, expected)
+
+ def test_pk_nonkey_range_col_untrusted(self):
+ # A PK table's non-PK column may be rewritten by merge
(aggregation/partial-update)
+ # beyond the footer min/max, so its bounds are untrusted -> every
split unknown.
+ schema = pa.schema([("id", pa.int64()), ("g", pa.int64())])
+ self._table("default.rj_agg", schema, [
+ pa.Table.from_pydict({"id": [1, 2], "g": [10, 20]},
schema=schema)],
+ primary_keys=["id"], options={"bucket": "1"})
+ ranged, _ = rjmod._plan_ranged_splits(
+ "default.rj_agg", self.catalog_options, None, "g")
+ self.assertTrue(ranged)
+ self.assertTrue(all(lo is None and hi is None for _, lo, hi in ranged))
+
+ def test_masked_range_col_untrusted(self):
+ from pypaimon.catalog.table_query_auth import TableQueryAuthResult
+ from pypaimon.read.query_auth_split import QueryAuthSplit
+
+ schema = pa.schema([("k", pa.int64())])
+ name = "default.rj_masked"
+ self._table(name, schema, [
+ pa.Table.from_pydict({"k": [1, 2, 3]}, schema=schema)])
+ table = self.catalog.get_table(name)
+ splits = list(table.new_read_builder().new_scan().plan().splits())
+ auth = TableQueryAuthResult(
+ None, {"k": "CAST(0 AS BIGINT)"})
+ masked = [QueryAuthSplit(split, auth) for split in splits]
+
+ self.assertFalse(rjmod._range_stats_trusted(table, masked, "k"))
+
+ def test_partition_key_validation(self):
+ loc = pa.schema([("k", pa.int64()), ("v", pa.string())])
+ self._table("default.rj_pv", loc, [
+ pa.Table.from_pydict({"k": [1], "v": ["a"]}, schema=loc)])
+ ins = pa.schema([("k", pa.int64())])
+ self._table("default.rj_pv_in", ins, [
+ pa.Table.from_pydict({"k": [1]}, schema=ins)])
+ with self.assertRaises(ValueError): # not a partition column
+ range_join("default.rj_pv_in", "default.rj_pv",
self.catalog_options,
+ on="k", left_projection=["k"], right_projection=["k",
"v"],
+ right_partitions={"nope": "a"})
+
+ def test_num_ranges_validation(self):
+ loc = pa.schema([("k", pa.int64()), ("v", pa.string())])
+ self._table("default.rj_nr", loc, [
+ pa.Table.from_pydict({"k": [1, 2], "v": ["a", "b"]}, schema=loc)])
+ ins = pa.schema([("k", pa.int64())])
+ self._table("default.rj_nr_in", ins, [
+ pa.Table.from_pydict({"k": [1]}, schema=ins)])
+ for bad in (0, -1, "5", 2.0):
+ with self.assertRaises(ValueError):
+ range_join("default.rj_nr_in", "default.rj_nr",
self.catalog_options,
+ on="k", left_projection=["k"],
+ right_projection=["k", "v"], num_ranges=bad)
+
+ def test_rejects_unrangeable_key_types(self):
+ # Rejected at the driver (not inside a worker): nested and tz-aware
keys.
+ arr = pa.schema([("k", pa.list_(pa.int64())), ("v", pa.string())])
+ self._table("default.rj_arr_a", arr, [])
+ self._table("default.rj_arr_b", arr, [])
+ with self.assertRaisesRegex(ValueError, "must not be"):
+ range_join("default.rj_arr_a", "default.rj_arr_b",
self.catalog_options, on="k")
+ ltz = pa.schema([("k", pa.timestamp("us", tz="UTC")), ("v",
pa.string())])
+ self._table("default.rj_ltz_a", ltz, [])
+ self._table("default.rj_ltz_b", ltz, [])
+ with self.assertRaisesRegex(ValueError, "must not be"):
+ range_join("default.rj_ltz_a", "default.rj_ltz_b",
self.catalog_options, on="k")
+ # A nested SECOND key is rejected too (every key is validated, not
just the range key).
+ multi = pa.schema([("k", pa.int64()), ("k2", pa.list_(pa.int64())),
("v", pa.string())])
+ self._table("default.rj_mk_a", multi, [])
+ self._table("default.rj_mk_b", multi, [])
+ with self.assertRaisesRegex(ValueError, "nested/complex"):
+ range_join("default.rj_mk_a", "default.rj_mk_b",
self.catalog_options, on=["k", "k2"])
+
+
+if __name__ == "__main__":
+ unittest.main()