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 0a9a930f6c [python][daft] Add Daft-side scan explain diagnostics
(#8017)
0a9a930f6c is described below
commit 0a9a930f6cb7bd8b919d324dc117f264caaef3ba
Author: QuakeWang <[email protected]>
AuthorDate: Sun May 31 20:41:06 2026 +0800
[python][daft] Add Daft-side scan explain diagnostics (#8017)
Daft's Paimon reader already chooses between native Parquet reads and
pypaimon fallback internally, but that routing decision was not
observable from the public Paimon Daft API. `ReadBuilder.explain()` only
describes the Paimon scan plan, so users could not diagnose whether a
slow scan was caused by PK merge, deletion vectors, BLOB columns,
non-Parquet format, or pushdown behavior.
This PR adds a structured Daft-side scan explain API:
- `explain_paimon_scan(...)`
- `PaimonTable.explain_scan(...)`
The result includes the underlying Paimon scan explain plus Daft reader
routing details: native/fallback split and file counts, fallback
reasons, pushed/remaining filters, projection/limit pushdown status, and
optional per-split reader mode.
The implementation reuses the same scan builder, partition filtering,
and native/fallback routing helpers used by
`PaimonDataSource.get_tasks()` to avoid divergence between diagnostics
and actual execution.
---
paimon-python/pypaimon/daft/__init__.py | 4 +-
paimon-python/pypaimon/daft/daft_catalog.py | 23 ++
paimon-python/pypaimon/daft/daft_datasource.py | 266 ++++++++++++---
paimon-python/pypaimon/daft/daft_explain.py | 160 +++++++++
paimon-python/pypaimon/daft/daft_paimon.py | 139 +++++++-
.../pypaimon/tests/daft/daft_explain_test.py | 368 +++++++++++++++++++++
6 files changed, 903 insertions(+), 57 deletions(-)
diff --git a/paimon-python/pypaimon/daft/__init__.py
b/paimon-python/pypaimon/daft/__init__.py
index e9651dd477..b854830173 100644
--- a/paimon-python/pypaimon/daft/__init__.py
+++ b/paimon-python/pypaimon/daft/__init__.py
@@ -16,9 +16,9 @@
# limitations under the License.
################################################################################
-from pypaimon.daft.daft_paimon import read_paimon, write_paimon
+from pypaimon.daft.daft_paimon import explain_paimon_scan, read_paimon,
write_paimon
-__all__ = ["read_paimon", "write_paimon", "PaimonCatalog", "PaimonTable"]
+__all__ = ["explain_paimon_scan", "read_paimon", "write_paimon",
"PaimonCatalog", "PaimonTable"]
def __getattr__(name):
diff --git a/paimon-python/pypaimon/daft/daft_catalog.py
b/paimon-python/pypaimon/daft/daft_catalog.py
index 2cbe479b81..d59df52dac 100644
--- a/paimon-python/pypaimon/daft/daft_catalog.py
+++ b/paimon-python/pypaimon/daft/daft_catalog.py
@@ -222,6 +222,29 @@ class PaimonTable(Table):
Table._validate_options("Paimon read", options, set())
return _read_table(self._inner, catalog_options=self._catalog_options)
+ def explain_scan(
+ self,
+ *,
+ filters: Any = None,
+ partition_filters: Any = None,
+ columns: list[str] | None = None,
+ limit: int | None = None,
+ io_config=None,
+ verbose: bool = False,
+ ) -> Any:
+ from pypaimon.daft.daft_paimon import _explain_table
+
+ return _explain_table(
+ self._inner,
+ catalog_options=self._catalog_options,
+ filters=filters,
+ partition_filters=partition_filters,
+ columns=columns,
+ limit=limit,
+ io_config=io_config,
+ verbose=verbose,
+ )
+
def append(self, df: DataFrame, **options: Any) -> None:
from pypaimon.daft.daft_paimon import _write_table
diff --git a/paimon-python/pypaimon/daft/daft_datasource.py
b/paimon-python/pypaimon/daft/daft_datasource.py
index 457fae375c..7e4419d2ab 100644
--- a/paimon-python/pypaimon/daft/daft_datasource.py
+++ b/paimon-python/pypaimon/daft/daft_datasource.py
@@ -18,7 +18,7 @@
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
import logging
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
@@ -32,6 +32,12 @@ from daft.logical.schema import Schema
from daft.recordbatch import RecordBatch
from pypaimon.daft.daft_compat import require_file_range_reads
+from pypaimon.daft.daft_explain import (
+ PaimonReaderSplitExplain,
+ PaimonScanExplain,
+ READER_MODE_NATIVE_PARQUET,
+ READER_MODE_PYPAIMON_FALLBACK,
+)
from pypaimon.daft.daft_predicate_visitor import convert_filters_to_paimon
if TYPE_CHECKING:
@@ -39,6 +45,7 @@ if TYPE_CHECKING:
from pypaimon.common.predicate import Predicate
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+ from pypaimon.read.explain import ExplainSplitInfo
from pypaimon.read.table_read import TableRead
from pypaimon.read.split import Split
from pypaimon.table.file_store_table import FileStoreTable
@@ -63,6 +70,16 @@ class _ReadPushdownState:
source_limit: int | None
+@dataclass(frozen=True, slots=True)
+class _ReaderRouting:
+ reader_mode: str
+ fallback_reason: str | None
+
+ @property
+ def use_native_reader(self) -> bool:
+ return self.reader_mode == READER_MODE_NATIVE_PARQUET
+
+
class _PaimonPKSplitTask(DataSourceTask):
"""DataSourceTask for PK-table splits that require LSM-tree merge.
@@ -189,6 +206,7 @@ class PaimonDataSource(DataSource):
else {}
)
+ self._pushed_filters: list[PyExpr] | None = None
self._paimon_predicate: Predicate | None = None
self._remaining_filters: list[PyExpr] | None = None
@@ -213,6 +231,7 @@ class PaimonDataSource(DataSource):
"""
pushed_filters, remaining_filters, paimon_predicate =
convert_filters_to_paimon(self._table, filters)
+ self._pushed_filters = pushed_filters
self._paimon_predicate = paimon_predicate
self._remaining_filters = remaining_filters
@@ -225,13 +244,17 @@ class PaimonDataSource(DataSource):
return pushed_filters, remaining_filters
- async def get_tasks(self, pushdowns: Pushdowns) ->
AsyncIterator[DataSourceTask]:
- read_table = self._table
+ def _read_table_for_scan(self) -> FileStoreTable:
if self._has_blob_columns:
- read_table = self._table.copy({"blob-as-descriptor": "true"})
+ return self._table.copy({"blob-as-descriptor": "true"})
+ return self._table
- read_builder = read_table.new_read_builder()
- read_pushdowns = self._read_pushdown_state(read_table, pushdowns)
+ def _scan_read_builder(
+ self,
+ table: FileStoreTable,
+ read_pushdowns: _ReadPushdownState,
+ ) -> Any:
+ read_builder = table.new_read_builder()
if read_pushdowns.requested_columns is not None:
read_builder =
read_builder.with_projection(read_pushdowns.requested_columns)
@@ -246,6 +269,13 @@ class PaimonDataSource(DataSource):
read_pushdowns.planning_predicate,
)
+ return read_builder
+
+ async def get_tasks(self, pushdowns: Pushdowns) ->
AsyncIterator[DataSourceTask]:
+ read_table = self._read_table_for_scan()
+ read_pushdowns = self._read_pushdown_state(read_table, pushdowns)
+ read_builder = self._scan_read_builder(read_table, read_pushdowns)
+
if self._table.partition_keys and pushdowns.partition_filters is None:
logger.warning(
"%s has partition keys %s but no partition filter was
specified. "
@@ -256,34 +286,21 @@ class PaimonDataSource(DataSource):
plan = read_builder.new_scan().plan()
- pv_cache: dict[tuple[Any, ...], RecordBatch | None] = {}
+ pv_cache: dict[tuple[tuple[str, Any], ...], RecordBatch | None] = {}
for split in plan.splits():
- if self._table.partition_keys and pushdowns.partition_filters is
not None:
- pv_key = tuple(sorted(split.partition.to_dict().items()))
- if pv_key not in pv_cache:
- pv_cache[pv_key] = self._build_partition_values(split)
- pv = pv_cache[pv_key]
- if pv is not None and
len(pv.filter(ExpressionsProjection([pushdowns.partition_filters]))) == 0:
- continue
-
- _deletion_files = getattr(split, "data_deletion_files", None)
- has_deletion_vectors = _deletion_files is not None and any(df is
not None for df in _deletion_files)
-
- can_use_native_reader = (
- self._is_parquet
- and not self._has_blob_columns
- and (not self._table.is_primary_key_table or
split.raw_convertible)
- and not has_deletion_vectors
+ if self._partition_filter_skips_split(split, pushdowns, pv_cache):
+ continue
+
+ routing = self._reader_routing(
+ raw_convertible=split.raw_convertible,
+ has_deletion_vectors=self._split_has_deletion_vectors(split),
)
- if can_use_native_reader:
+ if routing.use_native_reader:
pv = None
if self._table.partition_keys:
- pv_key = tuple(sorted(split.partition.to_dict().items()))
- if pv_key not in pv_cache:
- pv_cache[pv_key] = self._build_partition_values(split)
- pv = pv_cache[pv_key]
+ pv = self._partition_values(split, pv_cache)
for data_file in split.files:
file_uri =
self._build_file_uri(self._data_file_path(data_file))
@@ -297,18 +314,10 @@ class PaimonDataSource(DataSource):
storage_config=self._storage_config,
)
else:
- if not self._is_parquet:
- reason = "non-parquet format"
- elif self._has_blob_columns:
- reason = "blob columns present"
- elif has_deletion_vectors:
- reason = "deletion vectors present"
- else:
- reason = "LSM merge required"
logger.debug(
"Split with %d files using pypaimon fallback (%s).",
len(split.files),
- reason,
+ routing.fallback_reason,
)
yield _PaimonPKSplitTask(
self._fallback_read_builder(
@@ -323,6 +332,168 @@ class PaimonDataSource(DataSource):
self._blob_column_names,
)
+ def explain_scan(self, pushdowns: Pushdowns, verbose: bool = False) ->
PaimonScanExplain:
+ read_table = self._read_table_for_scan()
+ read_pushdowns = self._read_pushdown_state(read_table, pushdowns)
+ read_builder = self._scan_read_builder(read_table, read_pushdowns)
+
+ paimon_scan = read_builder.explain(verbose=True)
+ split_details = paimon_scan.splits or []
+
+ native_split_count = 0
+ native_file_count = 0
+ fallback_split_count = 0
+ fallback_file_count = 0
+ fallback_reasons: dict[str, int] = {}
+ explained_splits: list[PaimonReaderSplitExplain] | None = [] if
verbose else None
+ pv_cache: dict[tuple[tuple[str, Any], ...], RecordBatch | None] = {}
+
+ for split in split_details:
+ if self._partition_filter_skips_explain_split(split, pushdowns,
pv_cache):
+ continue
+
+ routing = self._reader_routing(
+ raw_convertible=split.raw_convertible,
+ has_deletion_vectors=split.has_deletion_vectors,
+ )
+ if routing.use_native_reader:
+ native_split_count += 1
+ native_file_count += split.file_count
+ else:
+ fallback_split_count += 1
+ fallback_file_count += split.file_count
+ reason = routing.fallback_reason or "unknown"
+ fallback_reasons[reason] = fallback_reasons.get(reason, 0) + 1
+
+ if explained_splits is not None:
+ explained_splits.append(
+ PaimonReaderSplitExplain(
+ partition=split.partition,
+ bucket=split.bucket,
+ file_count=split.file_count,
+ row_count=split.row_count,
+ file_size=split.file_size,
+ reader_mode=routing.reader_mode,
+ fallback_reason=routing.fallback_reason,
+ file_paths=split.file_paths,
+ )
+ )
+
+ if not verbose:
+ paimon_scan = replace(paimon_scan, splits=None)
+
+ pushed_filters, remaining_filters =
self._filter_pushdown_explain(pushdowns)
+ return PaimonScanExplain(
+ paimon_scan=paimon_scan,
+ native_parquet_split_count=native_split_count,
+ native_parquet_file_count=native_file_count,
+ pypaimon_fallback_split_count=fallback_split_count,
+ pypaimon_fallback_file_count=fallback_file_count,
+ fallback_reasons=fallback_reasons,
+ pushed_filters=pushed_filters,
+ remaining_filters=remaining_filters,
+ partition_filters=self._format_partition_filters(pushdowns),
+ requested_columns=read_pushdowns.requested_columns,
+ task_columns=read_pushdowns.task_columns,
+ fallback_read_columns=read_pushdowns.read_columns,
+ requested_limit=pushdowns.limit,
+ source_limit=read_pushdowns.source_limit,
+ limit_pushed=pushdowns.limit is not None and
read_pushdowns.source_limit == pushdowns.limit,
+ splits=explained_splits,
+ )
+
+ def _reader_routing(
+ self,
+ raw_convertible: bool,
+ has_deletion_vectors: bool,
+ ) -> _ReaderRouting:
+ can_use_native_reader = (
+ self._is_parquet
+ and not self._has_blob_columns
+ and (not self._table.is_primary_key_table or raw_convertible)
+ and not has_deletion_vectors
+ )
+ if can_use_native_reader:
+ return _ReaderRouting(READER_MODE_NATIVE_PARQUET, None)
+
+ if not self._is_parquet:
+ reason = "non-parquet format"
+ elif self._has_blob_columns:
+ reason = "blob columns present"
+ elif has_deletion_vectors:
+ reason = "deletion vectors present"
+ else:
+ reason = "LSM merge required"
+ return _ReaderRouting(READER_MODE_PYPAIMON_FALLBACK, reason)
+
+ @staticmethod
+ def _split_has_deletion_vectors(split: Split) -> bool:
+ deletion_files = getattr(split, "data_deletion_files", None)
+ return deletion_files is not None and any(df is not None for df in
deletion_files)
+
+ def _partition_filter_skips_split(
+ self,
+ split: Split,
+ pushdowns: Pushdowns,
+ pv_cache: dict[tuple[tuple[str, Any], ...], RecordBatch | None],
+ ) -> bool:
+ if not self._table.partition_keys or pushdowns.partition_filters is
None:
+ return False
+ pv = self._partition_values(split, pv_cache)
+ return self._partition_filter_skips_values(pv, pushdowns)
+
+ def _partition_filter_skips_explain_split(
+ self,
+ split: ExplainSplitInfo,
+ pushdowns: Pushdowns,
+ pv_cache: dict[tuple[tuple[str, Any], ...], RecordBatch | None],
+ ) -> bool:
+ if not self._table.partition_keys or pushdowns.partition_filters is
None:
+ return False
+ pv = self._partition_values_from_dict(split.partition, pv_cache)
+ return self._partition_filter_skips_values(pv, pushdowns)
+
+ @staticmethod
+ def _partition_filter_skips_values(
+ partition_values: RecordBatch | None,
+ pushdowns: Pushdowns,
+ ) -> bool:
+ return (
+ partition_values is not None
+ and
len(partition_values.filter(ExpressionsProjection([pushdowns.partition_filters])))
== 0
+ )
+
+ def _format_partition_filters(self, pushdowns: Pushdowns) -> list[str]:
+ if pushdowns.partition_filters is None:
+ return []
+ return self._format_pyexprs([getattr(pushdowns.partition_filters,
"_expr", pushdowns.partition_filters)])
+
+ def _filter_pushdown_explain(self, pushdowns: Pushdowns) ->
tuple[list[str], list[str]]:
+ if self._remaining_filters is not None:
+ return (
+ self._format_pyexprs(self._pushed_filters or []),
+ self._format_pyexprs(self._remaining_filters),
+ )
+
+ if pushdowns.filters is None:
+ return [], []
+
+ py_expr = getattr(pushdowns.filters, "_expr", pushdowns.filters)
+ pushed_filters, remaining_filters, _ =
convert_filters_to_paimon(self._table, [py_expr])
+ return self._format_pyexprs(pushed_filters),
self._format_pyexprs(remaining_filters)
+
+ @staticmethod
+ def _format_pyexprs(py_exprs: list[PyExpr]) -> list[str]:
+ from daft.expressions import Expression
+
+ result = []
+ for py_expr in py_exprs:
+ try:
+ result.append(str(Expression._from_pyexpr(py_expr)))
+ except Exception:
+ result.append(str(py_expr))
+ return result
+
def _build_file_uri(self, file_path: str) -> str:
"""Reconstruct a full URI from a (potentially scheme-stripped)
file_path."""
if urlparse(file_path).scheme:
@@ -337,10 +508,29 @@ class PaimonDataSource(DataSource):
def _build_partition_values(self, split: Split) ->
daft.recordbatch.RecordBatch | None:
"""Build a single-row RecordBatch encoding the partition values for a
split."""
+ return
self._build_partition_values_from_dict(split.partition.to_dict())
+
+ def _partition_values(
+ self,
+ split: Split,
+ pv_cache: dict[tuple[tuple[str, Any], ...], RecordBatch | None],
+ ) -> RecordBatch | None:
+ return self._partition_values_from_dict(split.partition.to_dict(),
pv_cache)
+
+ def _partition_values_from_dict(
+ self,
+ partition_dict: dict[str, Any],
+ pv_cache: dict[tuple[tuple[str, Any], ...], RecordBatch | None],
+ ) -> RecordBatch | None:
+ pv_key = tuple(sorted(partition_dict.items()))
+ if pv_key not in pv_cache:
+ pv_cache[pv_key] =
self._build_partition_values_from_dict(partition_dict)
+ return pv_cache[pv_key]
+
+ def _build_partition_values_from_dict(self, partition_dict: dict[str,
Any]) -> daft.recordbatch.RecordBatch | None:
if not self._table.partition_keys:
return None
- partition_dict = split.partition.to_dict()
arrays: dict[str, daft.Series] = {}
for pfield in self._table.partition_keys_fields:
value = partition_dict.get(pfield.name)
diff --git a/paimon-python/pypaimon/daft/daft_explain.py
b/paimon-python/pypaimon/daft/daft_explain.py
new file mode 100644
index 0000000000..6c97f393ae
--- /dev/null
+++ b/paimon-python/pypaimon/daft/daft_explain.py
@@ -0,0 +1,160 @@
+################################################################################
+# 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.
+################################################################################
+
+"""Structured explain result for Daft Paimon scans."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from pypaimon.read.explain import ExplainResult
+
+
+READER_MODE_NATIVE_PARQUET = "native_parquet"
+READER_MODE_PYPAIMON_FALLBACK = "pypaimon_fallback"
+
+
+@dataclass(frozen=True, slots=True)
+class PaimonReaderSplitExplain:
+ partition: dict[str, Any]
+ bucket: int
+ file_count: int
+ row_count: int
+ file_size: int
+ reader_mode: str
+ fallback_reason: str | None
+ file_paths: list[str]
+
+
+@dataclass(frozen=True, slots=True)
+class PaimonScanExplain:
+ paimon_scan: ExplainResult
+
+ native_parquet_split_count: int = 0
+ native_parquet_file_count: int = 0
+ pypaimon_fallback_split_count: int = 0
+ pypaimon_fallback_file_count: int = 0
+ fallback_reasons: dict[str, int] = field(default_factory=dict)
+
+ pushed_filters: list[str] = field(default_factory=list)
+ remaining_filters: list[str] = field(default_factory=list)
+ partition_filters: list[str] = field(default_factory=list)
+
+ requested_columns: list[str] | None = None
+ task_columns: list[str] | None = None
+ fallback_read_columns: list[str] | None = None
+
+ requested_limit: int | None = None
+ source_limit: int | None = None
+ limit_pushed: bool = False
+
+ splits: list[PaimonReaderSplitExplain] | None = None
+
+ @property
+ def total_split_count(self) -> int:
+ return self.native_parquet_split_count +
self.pypaimon_fallback_split_count
+
+ @property
+ def total_file_count(self) -> int:
+ return self.native_parquet_file_count +
self.pypaimon_fallback_file_count
+
+ def __str__(self) -> str:
+ return render_daft_paimon_explain(self)
+
+
+def render_daft_paimon_explain(result: PaimonScanExplain) -> str:
+ out = []
+ out.append("== Daft Paimon Scan ==")
+ _line(out, "Native Parquet splits", _count_files(
+ result.native_parquet_split_count,
+ result.native_parquet_file_count,
+ ))
+ _line(out, "pypaimon fallback splits", _count_files(
+ result.pypaimon_fallback_split_count,
+ result.pypaimon_fallback_file_count,
+ ))
+ _line(out, "Fallback reasons",
_format_reason_counts(result.fallback_reasons))
+ _line(out, "Pushed filters", _format_list(result.pushed_filters))
+ _line(out, "Remaining filters", _format_list(result.remaining_filters))
+ _line(out, "Partition filters", _format_list(result.partition_filters))
+ _line(out, "Requested columns",
_format_optional_list(result.requested_columns, "<all columns>"))
+ _line(out, "Task columns", _format_optional_list(result.task_columns,
"<all columns>"))
+ _line(out, "Fallback read columns", _format_optional_list(
+ result.fallback_read_columns,
+ "<all columns>",
+ ))
+ _line(out, "Limit", _format_limit(result))
+
+ if result.splits is not None:
+ out.append("")
+ out.append("Splits:")
+ for index, split in enumerate(result.splits):
+ suffix = "" if split.fallback_reason is None else "
({})".format(split.fallback_reason)
+ out.append(
+ " #{} bucket={} files={} rows={} size={} mode={}{}".format(
+ index,
+ split.bucket,
+ split.file_count,
+ split.row_count,
+ split.file_size,
+ split.reader_mode,
+ suffix,
+ )
+ )
+
+ out.append("")
+ out.append(str(result.paimon_scan).rstrip())
+ return "\n".join(out)
+
+
+def _line(out: list[str], key: str, value: str) -> None:
+ out.append("{:<28} {}".format(key + ":", value))
+
+
+def _count_files(split_count: int, file_count: int) -> str:
+ return "{} ({} files)".format(split_count, file_count)
+
+
+def _format_reason_counts(reasons: dict[str, int]) -> str:
+ if not reasons:
+ return "<none>"
+ return ", ".join("{}: {}".format(reason, count) for reason, count in
sorted(reasons.items()))
+
+
+def _format_list(values: list[str]) -> str:
+ if not values:
+ return "<none>"
+ return ", ".join(values)
+
+
+def _format_optional_list(values: list[str] | None, empty: str) -> str:
+ if values is None:
+ return empty
+ if not values:
+ return "[]"
+ return "[{}]".format(", ".join(values))
+
+
+def _format_limit(result: PaimonScanExplain) -> str:
+ if result.requested_limit is None:
+ return "<none>"
+ pushed = "pushed" if result.limit_pushed else "not pushed"
+ source = "<none>" if result.source_limit is None else
str(result.source_limit)
+ return "requested {}, source {} ({})".format(result.requested_limit,
source, pushed)
diff --git a/paimon-python/pypaimon/daft/daft_paimon.py
b/paimon-python/pypaimon/daft/daft_paimon.py
index 245cea534b..29825fbc11 100644
--- a/paimon-python/pypaimon/daft/daft_paimon.py
+++ b/paimon-python/pypaimon/daft/daft_paimon.py
@@ -20,20 +20,22 @@ Top-level API for reading and writing Paimon tables with
Daft DataFrames.
Usage::
- from pypaimon.daft import read_paimon, write_paimon
+ from pypaimon.daft import explain_paimon_scan, read_paimon, write_paimon
df = read_paimon("db.table", catalog_options={"warehouse": "/path"})
+ explain = explain_paimon_scan("db.table", catalog_options={"warehouse":
"/path"})
write_paimon(df, "db.table", catalog_options={"warehouse": "/path"})
"""
from __future__ import annotations
-from typing import TYPE_CHECKING, Dict, Optional
+from typing import TYPE_CHECKING, Any, Dict, Optional
from urllib.parse import urlparse
if TYPE_CHECKING:
import daft
+ from pypaimon.daft.daft_explain import PaimonScanExplain
from pypaimon.table.file_store_table import FileStoreTable
@@ -57,19 +59,31 @@ def _enrich_options_with_rest_token(
return enriched
-def _read_table(
+def _time_travel_table(
table: FileStoreTable,
- catalog_options: Dict[str, str] | None = None,
- io_config=None,
snapshot_id: int | None = None,
tag_name: str | None = None,
-) -> "daft.DataFrame":
- """Read a Paimon table object into a lazy Daft DataFrame."""
+) -> FileStoreTable:
if snapshot_id is not None and tag_name is not None:
raise ValueError(
"snapshot_id and tag_name cannot be set at the same time"
)
+ travel_options: dict[str, str] = {}
+ if snapshot_id is not None:
+ travel_options["scan.snapshot-id"] = str(snapshot_id)
+ if tag_name is not None:
+ travel_options["scan.tag-name"] = tag_name
+ if travel_options:
+ return table.copy(travel_options)
+ return table
+
+
+def _source_for_table(
+ table: FileStoreTable,
+ catalog_options: Dict[str, str] | None = None,
+ io_config=None,
+):
from daft import context, runners
from daft.daft import StorageConfig
@@ -78,14 +92,6 @@ def _read_table(
_convert_paimon_catalog_options_to_io_config,
)
- travel_options: dict[str, str] = {}
- if snapshot_id is not None:
- travel_options["scan.snapshot-id"] = str(snapshot_id)
- if tag_name is not None:
- travel_options["scan.tag-name"] = tag_name
- if travel_options:
- table = table.copy(travel_options)
-
if catalog_options is None:
catalog_options = {}
@@ -97,10 +103,71 @@ def _read_table(
multithreaded_io = runners.get_or_create_runner().name != "ray"
storage_config = StorageConfig(multithreaded_io, io_config)
- source = PaimonDataSource(
+ return PaimonDataSource(
table, storage_config=storage_config, catalog_options=catalog_options
)
- return source.read()
+
+
+def _read_table(
+ table: FileStoreTable,
+ catalog_options: Dict[str, str] | None = None,
+ io_config=None,
+ snapshot_id: int | None = None,
+ tag_name: str | None = None,
+) -> "daft.DataFrame":
+ """Read a Paimon table object into a lazy Daft DataFrame."""
+ table = _time_travel_table(table, snapshot_id=snapshot_id,
tag_name=tag_name)
+ return _source_for_table(table, catalog_options=catalog_options,
io_config=io_config).read()
+
+
+def _normalize_explain_filters(filters: Any) -> tuple[Any, list[Any]]:
+ if filters is None:
+ return None, []
+
+ if isinstance(filters, (list, tuple)):
+ if not filters:
+ return None, []
+ filter_exprs = list(filters)
+ combined = filter_exprs[0]
+ for filter_expr in filter_exprs[1:]:
+ combined = combined & filter_expr
+ else:
+ filter_exprs = [filters]
+ combined = filters
+
+ return combined, [getattr(filter_expr, "_expr", filter_expr) for
filter_expr in filter_exprs]
+
+
+def _explain_table(
+ table: FileStoreTable,
+ catalog_options: Dict[str, str] | None = None,
+ io_config=None,
+ snapshot_id: int | None = None,
+ tag_name: str | None = None,
+ filters: Any = None,
+ partition_filters: Any = None,
+ columns: list[str] | None = None,
+ limit: int | None = None,
+ verbose: bool = False,
+) -> "PaimonScanExplain":
+ """Explain a Paimon table object using Daft's datasource pushdown model."""
+ from daft.io.pushdowns import Pushdowns
+
+ table = _time_travel_table(table, snapshot_id=snapshot_id,
tag_name=tag_name)
+ source = _source_for_table(table, catalog_options=catalog_options,
io_config=io_config)
+ filter_expr, filter_pyexprs = _normalize_explain_filters(filters)
+ partition_filter_expr, _ = _normalize_explain_filters(partition_filters)
+ if filter_pyexprs:
+ source.push_filters(filter_pyexprs)
+ return source.explain_scan(
+ Pushdowns(
+ filters=filter_expr,
+ partition_filters=partition_filter_expr,
+ columns=columns,
+ limit=limit,
+ ),
+ verbose=verbose,
+ )
def _write_table(
@@ -159,6 +226,44 @@ def read_paimon(
)
+def explain_paimon_scan(
+ table_identifier: str,
+ catalog_options: Dict[str, str],
+ *,
+ filters: Any = None,
+ partition_filters: Any = None,
+ columns: list[str] | None = None,
+ limit: int | None = None,
+ snapshot_id: Optional[int] = None,
+ tag_name: Optional[str] = None,
+ io_config=None,
+ verbose: bool = False,
+) -> "PaimonScanExplain":
+ """Explain a Paimon scan through Daft's reader-routing layer.
+
+ The optional ``filters`` argument accepts a Daft expression or a list of
+ Daft expressions. Lists are treated as conjunctions, matching how multiple
+ pushed filters reach Daft datasources.
+ """
+ from pypaimon.catalog.catalog_factory import CatalogFactory
+
+ catalog = CatalogFactory.create(catalog_options)
+ table = catalog.get_table(table_identifier)
+
+ return _explain_table(
+ table,
+ catalog_options=catalog_options,
+ io_config=io_config,
+ snapshot_id=snapshot_id,
+ tag_name=tag_name,
+ filters=filters,
+ partition_filters=partition_filters,
+ columns=columns,
+ limit=limit,
+ verbose=verbose,
+ )
+
+
def write_paimon(
df: "daft.DataFrame",
table_identifier: str,
diff --git a/paimon-python/pypaimon/tests/daft/daft_explain_test.py
b/paimon-python/pypaimon/tests/daft/daft_explain_test.py
new file mode 100644
index 0000000000..0df051adf9
--- /dev/null
+++ b/paimon-python/pypaimon/tests/daft/daft_explain_test.py
@@ -0,0 +1,368 @@
+################################################################################
+# 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.
+################################################################################
+
+"""Tests for Daft-side Paimon scan explain diagnostics."""
+
+from __future__ import annotations
+
+import pyarrow as pa
+import pytest
+
+pypaimon = pytest.importorskip("pypaimon")
+daft = pytest.importorskip("daft")
+
+from daft import col
+
+from pypaimon.daft import explain_paimon_scan
+from pypaimon.daft.daft_catalog import PaimonTable
+from pypaimon.daft.daft_explain import (
+ READER_MODE_NATIVE_PARQUET,
+ READER_MODE_PYPAIMON_FALLBACK,
+)
+from pypaimon.daft.daft_compat import has_file_range_reads
+from pypaimon.daft.daft_datasource import PaimonDataSource
+from pypaimon.daft.daft_paimon import _explain_table
+from pypaimon.read.explain import ExplainResult, ExplainSplitInfo
+
+
+requires_blob = pytest.mark.skipif(not has_file_range_reads(), reason="BLOB
support requires daft >= 0.7.11")
+
+
[email protected]
+def catalog_options(tmp_path):
+ options = {"warehouse": str(tmp_path)}
+ catalog = pypaimon.CatalogFactory.create(options)
+ catalog.create_database("test_db", ignore_if_exists=True)
+ return options
+
+
+def _create_table(
+ catalog_options,
+ table_name: str,
+ pa_schema: pa.Schema,
+ *,
+ partition_keys: list[str] | None = None,
+ primary_keys: list[str] | None = None,
+ options: dict[str, str] | None = None,
+):
+ identifier = f"test_db.{table_name}"
+ catalog = pypaimon.CatalogFactory.create(catalog_options)
+ schema = pypaimon.Schema.from_pyarrow_schema(
+ pa_schema,
+ partition_keys=partition_keys,
+ primary_keys=primary_keys,
+ options=options,
+ )
+ catalog.create_table(identifier, schema, ignore_if_exists=False)
+ return identifier, catalog.get_table(identifier)
+
+
+def _write_arrow(table, data: pa.Table) -> None:
+ write_builder = table.new_batch_write_builder()
+ table_write = write_builder.new_write()
+ table_commit = write_builder.new_commit()
+ try:
+ table_write.write_arrow(data)
+ table_commit.commit(table_write.prepare_commit())
+ finally:
+ table_write.close()
+ table_commit.close()
+
+
+def _single_split_explain(
+ *,
+ table_identifier: str,
+ raw_convertible: bool,
+ has_deletion_vectors: bool,
+) -> ExplainResult:
+ split = ExplainSplitInfo(
+ partition={},
+ bucket=0,
+ file_count=1,
+ row_count=4,
+ merged_row_count=None,
+ file_size=128,
+ raw_convertible=raw_convertible,
+ has_deletion_vectors=has_deletion_vectors,
+ level_histogram={0: 1},
+ deletion_file_count=1 if has_deletion_vectors else 0,
+ file_paths=["/tmp/fake.parquet"],
+ )
+ return ExplainResult(
+ table_identifier=table_identifier,
+ is_primary_key_table=False,
+ bucket_mode="unaware",
+ deletion_vectors_enabled=has_deletion_vectors,
+ data_evolution_enabled=False,
+ snapshot_id=1,
+ schema_id=0,
+ file_count=1,
+ total_file_size=split.file_size,
+ estimated_row_count=split.row_count,
+ deletion_file_count=split.deletion_file_count,
+ level_histogram=split.level_histogram,
+ split_count=1,
+ splits_raw_convertible=1 if raw_convertible else 0,
+ splits_with_deletion_vectors=1 if has_deletion_vectors else 0,
+ files_per_split_min=1,
+ files_per_split_max=1,
+ files_per_split_avg=1.0,
+ split_size_min=split.file_size,
+ split_size_max=split.file_size,
+ split_size_avg=float(split.file_size),
+ split_size_p50=split.file_size,
+ split_size_p95=split.file_size,
+ splits=[split],
+ )
+
+
+def test_explain_paimon_scan_reports_native_parquet_routing(catalog_options):
+ pa_schema = pa.schema([
+ ("id", pa.int64()),
+ ("name", pa.string()),
+ ])
+ identifier, table = _create_table(
+ catalog_options,
+ "explain_native",
+ pa_schema,
+ options={"bucket": "-1", "file.format": "parquet"},
+ )
+ _write_arrow(table, pa.table({"id": [1, 2, 3], "name": ["a", "b", "c"]},
schema=pa_schema))
+
+ result = explain_paimon_scan(
+ identifier,
+ catalog_options,
+ filters=col("id") == 2,
+ columns=["name"],
+ limit=1,
+ verbose=True,
+ )
+
+ assert result.native_parquet_split_count == result.paimon_scan.split_count
+ assert result.native_parquet_split_count > 0
+ assert result.pypaimon_fallback_split_count == 0
+ assert result.fallback_reasons == {}
+ assert result.requested_columns == ["name"]
+ assert result.requested_limit == 1
+ assert result.source_limit == 1
+ assert result.limit_pushed is True
+ assert any("id" in pushed for pushed in result.pushed_filters)
+ assert result.remaining_filters == []
+ assert result.splits is not None
+ assert all(split.reader_mode == READER_MODE_NATIVE_PARQUET for split in
result.splits)
+ assert "Daft Paimon Scan" in str(result)
+ assert "PyPaimon Scan Plan" in str(result)
+
+
+def test_explain_scan_keeps_limit_above_remaining_filters(catalog_options):
+ pa_schema = pa.schema([
+ ("id", pa.int64()),
+ ("name", pa.string()),
+ ])
+ identifier, table = _create_table(
+ catalog_options,
+ "explain_remaining_filter",
+ pa_schema,
+ options={"bucket": "-1", "file.format": "parquet"},
+ )
+ _write_arrow(table, pa.table({"id": [1, 2], "name": ["a", "b"]},
schema=pa_schema))
+
+ result = PaimonTable(table, catalog_options=catalog_options).explain_scan(
+ filters=~(col("id") == 1),
+ limit=1,
+ )
+
+ assert result.native_parquet_split_count == result.paimon_scan.split_count
+ assert result.pypaimon_fallback_split_count == 0
+ assert result.pushed_filters == []
+ assert any("id" in remaining for remaining in result.remaining_filters)
+ assert result.source_limit is None
+ assert result.limit_pushed is False
+ assert result.splits is None
+ assert result.paimon_scan.splits is None
+
+
+def
test_explain_scan_applies_partition_filters_to_reader_counts(catalog_options):
+ pa_schema = pa.schema([
+ ("id", pa.int64()),
+ ("name", pa.string()),
+ ("dt", pa.string()),
+ ])
+ identifier, table = _create_table(
+ catalog_options,
+ "explain_partition_filter",
+ pa_schema,
+ partition_keys=["dt"],
+ options={"bucket": "1", "file.format": "parquet"},
+ )
+ _write_arrow(
+ table,
+ pa.table({"id": [1], "name": ["a"], "dt": ["2024-01-01"]},
schema=pa_schema),
+ )
+ _write_arrow(
+ table,
+ pa.table({"id": [2], "name": ["b"], "dt": ["2024-01-02"]},
schema=pa_schema),
+ )
+
+ result = explain_paimon_scan(
+ identifier,
+ catalog_options,
+ partition_filters=col("dt") == "2024-01-02",
+ verbose=True,
+ )
+
+ assert result.paimon_scan.split_count == 2
+ assert result.native_parquet_split_count == 1
+ assert result.pypaimon_fallback_split_count == 0
+ assert any("dt" in partition_filter for partition_filter in
result.partition_filters)
+ assert result.splits is not None
+ assert len(result.splits) == 1
+ assert result.splits[0].partition == {"dt": "2024-01-02"}
+
+
+def test_explain_scan_reports_pk_lsm_fallback(catalog_options):
+ pa_schema = pa.schema([
+ ("id", pa.int64()),
+ ("name", pa.string()),
+ ("dt", pa.string()),
+ ])
+ _, table = _create_table(
+ catalog_options,
+ "explain_pk_fallback",
+ pa_schema,
+ partition_keys=["dt"],
+ primary_keys=["id", "dt"],
+ options={"bucket": "1", "file.format": "parquet"},
+ )
+ _write_arrow(
+ table,
+ pa.table({"id": [1, 2], "name": ["old-a", "old-b"], "dt":
["2024-01-01", "2024-01-01"]}, schema=pa_schema),
+ )
+ _write_arrow(
+ table,
+ pa.table({"id": [1], "name": ["new-a"], "dt": ["2024-01-01"]},
schema=pa_schema),
+ )
+
+ result = _explain_table(
+ table,
+ catalog_options=catalog_options,
+ filters=col("id") == 1,
+ columns=["name"],
+ limit=1,
+ verbose=True,
+ )
+
+ assert result.pypaimon_fallback_split_count > 0
+ assert result.native_parquet_split_count == 0
+ assert result.fallback_reasons["LSM merge required"] ==
result.pypaimon_fallback_split_count
+ assert result.fallback_read_columns is not None
+ assert "name" in result.fallback_read_columns
+ assert "id" in result.fallback_read_columns
+ assert result.splits is not None
+ assert all(split.reader_mode == READER_MODE_PYPAIMON_FALLBACK for split in
result.splits)
+ assert all(split.fallback_reason == "LSM merge required" for split in
result.splits)
+
+
+def test_explain_scan_reports_non_parquet_fallback(catalog_options):
+ pa_schema = pa.schema([
+ ("id", pa.int64()),
+ ("name", pa.string()),
+ ])
+ _, table = _create_table(
+ catalog_options,
+ "explain_avro_fallback",
+ pa_schema,
+ options={"bucket": "-1", "file.format": "avro"},
+ )
+ _write_arrow(table, pa.table({"id": [1], "name": ["a"]}, schema=pa_schema))
+
+ result = _explain_table(table, catalog_options=catalog_options,
verbose=True)
+
+ assert result.pypaimon_fallback_split_count ==
result.paimon_scan.split_count
+ assert result.pypaimon_fallback_split_count > 0
+ assert result.native_parquet_split_count == 0
+ assert result.fallback_reasons["non-parquet format"] ==
result.pypaimon_fallback_split_count
+ assert result.splits is not None
+ assert all(split.fallback_reason == "non-parquet format" for split in
result.splits)
+
+
+@requires_blob
+def test_explain_scan_reports_blob_fallback(catalog_options):
+ pa_schema = pa.schema([
+ ("id", pa.int64()),
+ ("payload", pa.large_binary()),
+ ])
+ _, table = _create_table(
+ catalog_options,
+ "explain_blob_fallback",
+ pa_schema,
+ options={
+ "bucket": "-1",
+ "file.format": "parquet",
+ "row-tracking.enabled": "true",
+ "data-evolution.enabled": "true",
+ },
+ )
+ _write_arrow(table, pa.table({"id": [1], "payload": [b"hello"]},
schema=pa_schema))
+
+ result = _explain_table(table, catalog_options=catalog_options,
verbose=True)
+
+ assert result.pypaimon_fallback_split_count ==
result.paimon_scan.split_count
+ assert result.pypaimon_fallback_split_count > 0
+ assert result.native_parquet_split_count == 0
+ assert result.fallback_reasons["blob columns present"] ==
result.pypaimon_fallback_split_count
+ assert result.splits is not None
+ assert all(split.reader_mode == READER_MODE_PYPAIMON_FALLBACK for split in
result.splits)
+ assert all(split.fallback_reason == "blob columns present" for split in
result.splits)
+
+
+def test_explain_scan_reports_deletion_vector_fallback(catalog_options,
monkeypatch):
+ pa_schema = pa.schema([
+ ("id", pa.int64()),
+ ("name", pa.string()),
+ ])
+ _, table = _create_table(
+ catalog_options,
+ "explain_deletion_vector_fallback",
+ pa_schema,
+ options={"bucket": "-1", "file.format": "parquet"},
+ )
+
+ class FakeReadBuilder:
+ def explain(self, verbose: bool = False) -> ExplainResult:
+ assert verbose is True
+ return _single_split_explain(
+ table_identifier="test_db.explain_deletion_vector_fallback",
+ raw_convertible=True,
+ has_deletion_vectors=True,
+ )
+
+ def fake_scan_read_builder(self, table, read_pushdowns):
+ return FakeReadBuilder()
+
+ monkeypatch.setattr(PaimonDataSource, "_scan_read_builder",
fake_scan_read_builder)
+
+ result = _explain_table(table, catalog_options=catalog_options,
verbose=True)
+
+ assert result.pypaimon_fallback_split_count == 1
+ assert result.native_parquet_split_count == 0
+ assert result.fallback_reasons == {"deletion vectors present": 1}
+ assert result.splits is not None
+ assert len(result.splits) == 1
+ assert result.splits[0].reader_mode == READER_MODE_PYPAIMON_FALLBACK
+ assert result.splits[0].fallback_reason == "deletion vectors present"