blob: 6886d4bfce234bb8d229c42ae08d29029e51c752 [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.
"""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)