blob: 9197d4631fe3b7608b20ed198ee358e76ed21f0b [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.
"""Build public explain results from one selected backend's plan and counters."""
from typing import List, Optional
from pypaimon.read.explain import ExplainResult, ExplainSplitInfo, PruningStat
from pypaimon.read.explain_render import render_predicate
from pypaimon.read.query_auth_split import QueryAuthSplit
from pypaimon.read.scan_stats import ScanStats
from pypaimon.read.split import Split
from pypaimon.read.table_scan import TableScan
def explain(builder, verbose: bool = False) -> ExplainResult:
"""Produce a structured scan plan for this builder.
Runs one planning pass (manifest list + manifest reads, no data
files) and returns an :class:`ExplainResult` summarising the
target snapshot, the pushed-down predicate / projection / limit,
the partition / bucket / file-stats pruning funnel, and split-
level execution signals (raw-convertible ratio, deletion-vector
ratio, level histogram, files-per-split and split-size
distribution). With ``verbose=True``, every split is listed.
Cost: ``explain()`` reads manifest list + manifests but never
opens data files. To produce accurate before/after counters it
suppresses early bucket and sidecar pruning and counts candidates
before applying LIMIT, so it can be measurably
heavier than a regular ``new_scan().plan()`` on tables where the
early filter usually prunes aggressively (e.g. very wide
HASH_FIXED tables with a tight predicate).
"""
scan = builder.new_scan()
plan, stats = scan.scan_with_stats()
return _build_explain_result(
table=builder.table,
scan=scan,
plan=plan,
stats=stats,
predicate=builder._predicate,
projection=([alias for alias, _ in builder._output_projection.columns]
if builder._output_projection else None),
limit=builder._limit,
verbose=verbose,
)
def _build_explain_result(table, scan: TableScan, plan, stats: Optional[ScanStats],
predicate, projection, limit, verbose: bool) -> ExplainResult:
"""Translate one (Plan, ScanStats) pair into an ExplainResult."""
splits: List[Split] = plan.splits()
table_schema = table.table_schema
bucket_mode_str = _safe_bucket_mode(table)
native_planned = stats is None or stats.native_planned
if stats is None or native_planned:
partition_pruning = bucket_pruning = file_skipping = None
else:
partition_pruning = _partition_pruning(stats, scan)
bucket_pruning = _bucket_pruning(stats, scan)
file_skipping = _file_skipping(stats, scan)
files_per_split = [len(getattr(s, 'files', []) or []) for s in splits]
sizes = [int(getattr(s, 'file_size', 0) or 0) for s in splits]
rows_total = sum(int(getattr(s, 'row_count', 0) or 0) for s in splits)
merged_per_split = [s.merged_row_count() for s in splits]
if splits and all(v is not None for v in merged_per_split):
merged_total: Optional[int] = sum(merged_per_split)
else:
merged_total = None
file_count = sum(files_per_split)
total_size = sum(sizes)
level_hist: dict = {}
deletion_file_total = 0
splits_raw_convertible = 0
splits_with_dv = 0
splits_all_above_l0 = 0
split_infos: List[ExplainSplitInfo] = []
plan_has_auth = any(isinstance(s, QueryAuthSplit) for s in splits)
for split in splits:
files = getattr(split, 'files', []) or []
per_split_levels: dict = {}
for f in files:
lv = getattr(f, 'level', 0) or 0
level_hist[lv] = level_hist.get(lv, 0) + 1
per_split_levels[lv] = per_split_levels.get(lv, 0) + 1
dvs = getattr(split, 'data_deletion_files', None) or []
dv_count_here = sum(1 for d in dvs if d is not None)
deletion_file_total += dv_count_here
has_dv = dv_count_here > 0
raw = bool(getattr(split, 'raw_convertible', False))
if raw:
splits_raw_convertible += 1
if has_dv:
splits_with_dv += 1
if files and all((getattr(f, 'level', 0) or 0) > 0 for f in files):
splits_all_above_l0 += 1
if verbose:
split_infos.append(ExplainSplitInfo(
partition=_format_partition(split, table),
bucket=int(getattr(split, 'bucket', -1)),
file_count=len(files),
row_count=int(getattr(split, 'row_count', 0) or 0),
merged_row_count=split.merged_row_count(),
file_size=int(getattr(split, 'file_size', 0) or 0),
raw_convertible=raw,
has_deletion_vectors=has_dv,
level_histogram=per_split_levels,
deletion_file_count=dv_count_here,
file_paths=list(getattr(split, 'file_paths', []) or []),
data_files=list(files),
))
fps_min, fps_max, fps_avg = _min_max_avg(files_per_split)
sz_min, sz_max, sz_avg = _min_max_avg(sizes)
sz_p50 = _percentile(sizes, 50)
sz_p95 = _percentile(sizes, 95)
return ExplainResult(
table_identifier=str(table.identifier.get_full_name()),
is_primary_key_table=bool(table.is_primary_key_table),
bucket_mode=bucket_mode_str,
deletion_vectors_enabled=bool(table.options.deletion_vectors_enabled()),
data_evolution_enabled=bool(table.options.data_evolution_enabled()),
snapshot_id=plan.snapshot_id,
schema_id=table_schema.id if plan.snapshot_id is not None else None,
predicate=render_predicate(predicate) if predicate is not None else None,
projection=list(projection) if projection else None,
limit=limit,
partition_pruning=partition_pruning,
bucket_pruning=bucket_pruning,
file_skipping=file_skipping,
file_count=file_count,
total_file_size=total_size,
estimated_row_count=rows_total,
estimated_merged_row_count=merged_total,
deletion_file_count=deletion_file_total,
level_histogram=level_hist,
split_count=len(splits),
splits_raw_convertible=splits_raw_convertible,
splits_with_deletion_vectors=splits_with_dv,
splits_all_above_l0=splits_all_above_l0,
files_per_split_min=fps_min,
files_per_split_max=fps_max,
files_per_split_avg=fps_avg,
split_size_min=sz_min,
split_size_max=sz_max,
split_size_avg=sz_avg,
split_size_p50=sz_p50,
split_size_p95=sz_p95,
has_auth=plan_has_auth,
native_planned=native_planned,
planning_counters=stats.planning_counters if stats is not None else None,
splits=split_infos if verbose else None,
)
def _partition_pruning(stats: ScanStats, scan: TableScan) -> Optional[PruningStat]:
if scan.predicate is None:
return None
table_partition_keys = scan.table.partition_keys or []
if not table_partition_keys:
return None
# ``entries_potential_total`` is the count from manifest-file metadata
# (manifest-level pruning has not been applied yet). The "after" side
# is everything that survived both manifest-stats and per-entry
# partition filters.
return PruningStat(
before=stats.entries_potential_total,
after=stats.entries_after_partition,
)
def _bucket_pruning(stats: ScanStats, scan: TableScan) -> Optional[PruningStat]:
# Visible whenever the scan applies any bucket-level filtering — the
# HASH_FIXED predicate-driven selector OR the POSTPONE_BUCKET
# synthetic-bucket skip. Tables with neither (e.g. BUCKET_UNAWARE
# append) leave this counter as ``None``.
from pypaimon.read.read_dispatch import file_scan_has_bucket_pruning
if not file_scan_has_bucket_pruning(scan):
return None
return PruningStat(before=stats.entries_after_partition, after=stats.entries_after_bucket)
def _file_skipping(stats: ScanStats, scan: TableScan) -> Optional[PruningStat]:
# Captures the funnel between bucket-stage survivors and the entries
# that actually feed the split generator. The drop here includes both
# predicate-driven file-stats pruning AND structural skips that fire
# in ``_filter_manifest_entry`` once a file is fully decoded (most
# notably the "do not read level-0 file" rule for DV-enabled PK
# tables, which is an LSM-shape decision rather than a predicate
# test).
if scan.predicate is None:
return None
return PruningStat(before=stats.entries_after_bucket, after=stats.entries_after_stats)
def _safe_bucket_mode(table) -> str:
try:
return table.bucket_mode().name
except Exception:
return "UNKNOWN"
def _format_partition(split, table) -> dict:
keys = list(table.partition_keys or [])
partition = getattr(split, 'partition', None)
if partition is None or not keys:
return {}
values = getattr(partition, 'values', None) or []
return {k: v for k, v in zip(keys, values)}
def _min_max_avg(values):
if not values:
return 0, 0, 0.0
return min(values), max(values), sum(values) / float(len(values))
def _percentile(values, pct: int) -> int:
if not values:
return 0
ordered = sorted(values)
idx = int(round((pct / 100.0) * (len(ordered) - 1)))
return int(ordered[idx])