Skip to content

Commit 5f05dce

Browse files
committed
Exact table statistics for the optimizer (DataFusion 54)
Report exact statistics from the xarray scan so DataFusion's cost-based optimizer can plan joins and aggregations well — without a second engine. DataFusion 54's datafusion-ffi forwards ExecutionPlan statistics across the FFI boundary (52/53 dropped them), so the statistics the scan reports now reach the optimizer on the ordinary path. - XarrayScanExec wraps the StreamingTableExec from scan() and reports exact Statistics: num_rows is the summed product of each chunk's dimension sizes (exact, not an estimate), plus exact min/max for numeric dimension columns. Per-partition row counts are plumbed from Python as a third tuple element (factory, metadata, num_rows); the 2-tuple form still works. - Upgrade datafusion + datafusion-ffi 52 -> 54 (and arrow 57 -> 58, pyo3 0.26 -> 0.28 to match), and the datafusion Python dep to 54. Verified: a big-vs-small join now plans as HashJoinExec mode=CollectLeft with the small side's Rows=Exact(64) carried through the FFI boundary (FFI_ExecutionPlan: XarrayScanExec), and COUNT(*) is answered from the exact statistics without scanning. The reader tests that used COUNT(*) to force a scan now use SELECT * (COUNT(*) is metadata-only once statistics are exact). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VuSeCio99NcME5eubcN3N
1 parent 6ee981d commit 5f05dce

8 files changed

Lines changed: 903 additions & 490 deletions

File tree

Cargo.lock

Lines changed: 407 additions & 415 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,22 @@ exclude = [
1818
]
1919

2020
[dependencies]
21-
arrow = { version = "57.2.0", features = ["pyarrow"] }
21+
arrow = { version = "58", features = ["pyarrow"] }
2222
async-stream = "0.3"
2323
async-trait = "0.1"
24-
datafusion = { version = "52.0.0" }
25-
datafusion-ffi = { version = "52.0.0" }
24+
datafusion = { version = "54.0.0" }
25+
datafusion-ffi = { version = "54.0.0" }
2626
futures = { version = "0.3" }
2727
# `abi3-py310` builds against CPython's stable ABI, so a single wheel per
2828
# platform works on all CPython >= 3.10 (matching `requires-python`). This
2929
# lets the release workflow ship pre-built wheels for every interpreter
3030
# without compiling per-version, avoiding local rebuilds on install.
31-
pyo3 = { version = "0.26.0", features = ["extension-module", "abi3-py310"] }
31+
pyo3 = { version = "0.28.0", features = ["extension-module", "abi3-py310"] }
3232
tokio = { version = "1.46.1", features = ["rt"] }
3333

3434

3535
[build-dependencies]
36-
pyo3-build-config = "0.26"
36+
pyo3-build-config = "0.28"
3737

3838
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
3939
[lib]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ classifiers = [
3131
]
3232
dependencies = [
3333
"dask>=2024.8.0",
34-
"datafusion==52.0.0", # This needs to match the cargo datafusion version!!
34+
"datafusion==54.0.0", # This needs to match the cargo datafusion version!!
3535
"xarray>=2024.7.0",
3636
]
3737

src/lib.rs

Lines changed: 328 additions & 42 deletions
Large diffs are not rendered by default.

tests/test_reader.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ def test_full_query_iterates_all_blocks(self, small_ds):
212212
ctx.register_table("test_table", table)
213213

214214
# Run a query that needs to scan all data
215-
ctx.sql("SELECT COUNT(*) FROM test_table").collect()
215+
ctx.sql("SELECT * FROM test_table").collect()
216216

217217
# With time=100 and chunks=25, we expect 4 blocks
218218
expected_blocks = 100 // 25
@@ -318,7 +318,7 @@ def test_multiple_queries_on_same_table(self, small_ds):
318318
ctx.register_table("test_table", table)
319319

320320
# First query
321-
ctx.sql("SELECT COUNT(*) FROM test_table").collect()
321+
ctx.sql("SELECT * FROM test_table").collect()
322322
first_query_iterations = tracker.iteration_count
323323
assert first_query_iterations > 0, "First query should iterate"
324324

@@ -358,8 +358,8 @@ def test_query_results_are_correct(self, small_ds):
358358
ctx.register_table("test_table", table)
359359

360360
# Get count
361-
result = ctx.sql("SELECT COUNT(*) as cnt FROM test_table").collect()
362-
count = result[0].to_pandas()["cnt"].iloc[0]
361+
result = ctx.sql("SELECT * FROM test_table").collect()
362+
count = sum(b.num_rows for b in result)
363363

364364
# Expected: 100 time steps * 10 lat * 10 lon = 10,000 rows
365365
expected_count = 100 * 10 * 10
@@ -512,7 +512,7 @@ def test_batches_processed_incrementally(self, small_ds):
512512
ctx.register_table("test_table", table)
513513

514514
# Run query that scans all data
515-
ctx.sql("SELECT COUNT(*) FROM test_table").collect()
515+
ctx.sql("SELECT * FROM test_table").collect()
516516

517517
# All 4 batches should have been processed
518518
assert tracker.batch_count == 4, (
@@ -580,8 +580,8 @@ def test_large_dataset_streams_correctly(self):
580580
ctx.register_table("test_table", table)
581581

582582
# Run a query that needs all data
583-
result = ctx.sql("SELECT COUNT(*) as cnt FROM test_table").collect()
584-
count = result[0].to_pandas()["cnt"].iloc[0]
583+
result = ctx.sql("SELECT * FROM test_table").collect()
584+
count = sum(b.num_rows for b in result)
585585

586586
# Verify all blocks were processed
587587
assert tracker.batch_count == 20, (
@@ -639,8 +639,8 @@ def test_many_batches_stream_successfully(self):
639639
ctx = SessionContext()
640640
ctx.register_table("test_table", table)
641641

642-
result = ctx.sql("SELECT COUNT(*) as cnt FROM test_table").collect()
643-
count = result[0].to_pandas()["cnt"].iloc[0]
642+
result = ctx.sql("SELECT * FROM test_table").collect()
643+
count = sum(b.num_rows for b in result)
644644

645645
# All 16 batches should have been processed
646646
assert tracker.batch_count == 16, (
@@ -722,8 +722,8 @@ def test_large_batch_count_completes(self):
722722
ctx = SessionContext()
723723
ctx.register_table("test_table", table)
724724

725-
result = ctx.sql("SELECT COUNT(*) as cnt FROM test_table").collect()
726-
count = result[0].to_pandas()["cnt"].iloc[0]
725+
result = ctx.sql("SELECT * FROM test_table").collect()
726+
count = sum(b.num_rows for b in result)
727727

728728
# All 50 batches processed
729729
assert tracker.batch_count == 50, (
@@ -792,8 +792,8 @@ def failing_factory():
792792
raise ValueError("Factory intentionally failed")
793793

794794
schema = pa.schema([("value", pa.int64())])
795-
# partitions is an iterable of (factory, metadata_dict) pairs
796-
table = LazyArrowStreamTable([(failing_factory, {})], schema)
795+
# partitions is an iterable of (factory, metadata_dict, num_rows) tuples
796+
table = LazyArrowStreamTable([(failing_factory, {}, 1)], schema)
797797

798798
ctx = SessionContext()
799799
ctx.register_table("test_table", table)
@@ -860,8 +860,8 @@ def test_empty_dataset_handled_gracefully(self):
860860
ctx = SessionContext()
861861
ctx.register_table("test_table", table)
862862

863-
result = ctx.sql("SELECT COUNT(*) as cnt FROM test_table").collect()
864-
count = result[0].to_pandas()["cnt"].iloc[0]
863+
result = ctx.sql("SELECT * FROM test_table").collect()
864+
count = sum(b.num_rows for b in result)
865865

866866
assert count == 0, f"Expected 0 rows for empty dataset, got {count}"
867867

@@ -889,7 +889,7 @@ def counting_callback(block, projection_names=None):
889889
ctx.register_table("test_table", table)
890890

891891
# First query
892-
ctx.sql("SELECT COUNT(*) FROM test_table").collect()
892+
ctx.sql("SELECT * FROM test_table").collect()
893893
first_query_count = call_count["value"]
894894
assert first_query_count == 2, (
895895
f"First query: expected 2, got {first_query_count}"
@@ -933,8 +933,8 @@ def test_parallel_queries_independent(self, small_ds):
933933
ctx2.register_table("test_table", table2)
934934

935935
# Execute queries
936-
ctx1.sql("SELECT COUNT(*) FROM test_table").collect()
937-
ctx2.sql("SELECT COUNT(*) FROM test_table").collect()
936+
ctx1.sql("SELECT * FROM test_table").collect()
937+
ctx2.sql("SELECT * FROM test_table").collect()
938938

939939
# Each should have its own iteration count
940940
assert tracker1.iteration_count == 4, (

tests/test_stats.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Exact table statistics reach the optimizer through the FFI boundary.
2+
3+
DataFusion 54 forwards ``Statistics`` across the ``datafusion-ffi`` boundary,
4+
so the exact statistics ``XarrayScanExec`` reports are visible to the query
5+
optimizer: num_rows (product of a chunk's dimension sizes), total byte size,
6+
and per dimension-column min/max bounds. These tests pin that behaviour.
7+
"""
8+
9+
import numpy as np
10+
import xarray as xr
11+
12+
from xarray_sql import XarrayContext
13+
14+
15+
def _explain(ctx: XarrayContext, query: str) -> str:
16+
ctx.sql("SET datafusion.explain.show_statistics = true").collect()
17+
rows = ctx.sql(f"EXPLAIN {query}").to_pandas()
18+
return "\n".join(rows["plan"].tolist())
19+
20+
21+
def test_exact_rows_in_scan_statistics():
22+
"""The scan reports exact row counts (forwarded across FFI)."""
23+
ds = xr.Dataset(
24+
{"air": (("time", "lat", "lon"), np.random.rand(100, 4, 5))},
25+
coords={
26+
"time": np.arange(100),
27+
"lat": np.arange(4),
28+
"lon": np.arange(5),
29+
},
30+
)
31+
ctx = XarrayContext()
32+
ctx.from_dataset("air", ds, chunks={"time": 50})
33+
plan = _explain(ctx, "SELECT lat, lon, air FROM air")
34+
total = 100 * 4 * 5
35+
assert f"Rows=Exact({total})" in plan
36+
37+
38+
def test_exact_byte_size_in_scan_statistics():
39+
"""The scan reports exact byte size (num_rows x fixed row width)."""
40+
ds = xr.Dataset(
41+
{"air": (("time", "lat", "lon"), np.random.rand(100, 4, 5))},
42+
coords={
43+
"time": np.arange(100),
44+
"lat": np.arange(4),
45+
"lon": np.arange(5),
46+
},
47+
)
48+
ctx = XarrayContext()
49+
ctx.from_dataset("air", ds, chunks={"time": 50})
50+
plan = _explain(ctx, "SELECT lat, lon, air FROM air")
51+
# 2000 rows x (lat int64 + lon int64 + air float64) = 2000 x 24 bytes.
52+
assert f"Bytes=Exact({100 * 4 * 5 * 24})" in plan
53+
54+
55+
def test_dimension_column_min_max_in_scan_statistics():
56+
"""Dimension columns carry exact min/max and a zero null count.
57+
58+
These are the join/filter key columns; the bounds come from the same
59+
coordinate metadata used for partition pruning (no data scan), and grid
60+
axes are always fully populated so the null count is exactly zero.
61+
"""
62+
ds = xr.Dataset(
63+
{"air": (("time", "lat", "lon"), np.random.rand(100, 4, 5))},
64+
coords={
65+
"time": np.arange(100),
66+
"lat": np.arange(4),
67+
"lon": np.arange(5),
68+
},
69+
)
70+
ctx = XarrayContext()
71+
ctx.from_dataset("air", ds, chunks={"time": 50})
72+
plan = _explain(ctx, "SELECT lat, lon, air FROM air")
73+
# lat spans 0..3, lon spans 0..4, both never null.
74+
assert "Min=Exact(Int64(0)) Max=Exact(Int64(3)) Null=Exact(0)" in plan
75+
assert "Min=Exact(Int64(0)) Max=Exact(Int64(4)) Null=Exact(0)" in plan
76+
77+
78+
def test_count_star_answered_from_statistics():
79+
"""COUNT(*) returns the exact count from statistics (metadata only)."""
80+
ds = xr.Dataset(
81+
{"air": (("time", "lat", "lon"), np.random.rand(100, 4, 5))},
82+
coords={
83+
"time": np.arange(100),
84+
"lat": np.arange(4),
85+
"lon": np.arange(5),
86+
},
87+
)
88+
ctx = XarrayContext()
89+
ctx.from_dataset("air", ds, chunks={"time": 50})
90+
n = ctx.sql("SELECT COUNT(*) AS n FROM air").to_pandas()["n"][0]
91+
assert int(n) == 100 * 4 * 5
92+
93+
94+
def test_join_picks_small_build_side():
95+
"""With exact stats the optimizer broadcasts the smaller table (CollectLeft).
96+
97+
Without statistics (the pre-54 FFI path) the optimizer could not know which
98+
side was smaller and fell back to a Partitioned hash join.
99+
"""
100+
rng = np.random.default_rng(0)
101+
big = xr.Dataset(
102+
{"t": (("time", "lat", "lon"), rng.standard_normal((200, 8, 8)))},
103+
coords={
104+
"time": np.arange(200),
105+
"lat": np.arange(8),
106+
"lon": np.arange(8),
107+
},
108+
)
109+
small = xr.Dataset(
110+
{"w": (("lat", "lon"), rng.standard_normal((8, 8)))},
111+
coords={"lat": np.arange(8), "lon": np.arange(8)},
112+
)
113+
ctx = XarrayContext()
114+
ctx.from_dataset("big", big, chunks={"time": 50})
115+
ctx.from_dataset("small", small, chunks={"lat": 8})
116+
117+
plan = _explain(
118+
ctx,
119+
"SELECT b.time, SUM(b.t * s.w) AS x FROM big b "
120+
"JOIN small s ON b.lat=s.lat AND b.lon=s.lon GROUP BY b.time",
121+
)
122+
assert "HashJoinExec: mode=CollectLeft" in plan
123+
# The small (build) side's exact cardinality crossed the FFI boundary.
124+
assert "Rows=Exact(64)" in plan

uv.lock

Lines changed: 14 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

xarray_sql/reader.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
Block,
2222
Chunks,
2323
DEFAULT_BATCH_SIZE,
24+
_block_len,
2425
_block_metadata,
2526
_block_slices_from_resolved,
2627
_parse_schema,
@@ -315,7 +316,7 @@ def make_stream(
315316
)
316317

317318
def partition_pairs():
318-
"""Lazily yield (factory, metadata) for each partition.
319+
"""Lazily yield (factory, metadata, num_rows) for each partition.
319320
320321
Consuming this generator one item at a time means Python never holds
321322
all N block dicts, metadata dicts, and factory closures simultaneously.
@@ -327,6 +328,10 @@ def partition_pairs():
327328
yield (
328329
make_partition_factory(block),
329330
{**static_ranges, **dynamic},
331+
# Exact row count for this partition (product of the chunk's
332+
# per-dimension sizes), so the scan can report exact
333+
# Statistics::num_rows to the optimizer.
334+
_block_len(block),
330335
)
331336

332337
return LazyArrowStreamTable(partition_pairs(), schema)

0 commit comments

Comments
 (0)