From 3c108a7bbcbc109a56a62021e8af89083c2c0469 Mon Sep 17 00:00:00 2001
From: Narendra
Date: Thu, 13 Aug 2026 21:09:24 +0530
Subject: [PATCH 01/50] feat: add sphinx-sitemap dependency and configuration
for sitemap generation
---
docs/pyproject.toml | 1 +
docs/source/conf.py | 17 +++++++++++++++--
uv.lock | 27 +++++++++++++++++++++++++++
3 files changed, 43 insertions(+), 2 deletions(-)
diff --git a/docs/pyproject.toml b/docs/pyproject.toml
index c09415e8e8c86..c4edfdcfec38e 100644
--- a/docs/pyproject.toml
+++ b/docs/pyproject.toml
@@ -10,4 +10,5 @@ dependencies = [
"maturin>=1.14.1,<2",
"jinja2>=3.1.6,<4",
"setuptools>=83.0.0,<84",
+ "sphinx-sitemap>=2.9.0,<3",
]
diff --git a/docs/source/conf.py b/docs/source/conf.py
index c8027fc71bd54..81ffce3ad1c2b 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -55,6 +55,7 @@
"myst_parser",
"sphinx_reredirects",
"rustdoc_trim",
+ "sphinx_sitemap",
]
source_suffix = {
@@ -89,8 +90,8 @@
html_theme_options = {
"logo": {
- "image_light": "_static/images/original.svg",
- "image_dark": "_static/images/original_dark.svg",
+ "image_light": "_static/images/original.svg",
+ "image_dark": "_static/images/original_dark.svg",
},
"use_edit_page_button": True,
"navbar_center": [],
@@ -113,6 +114,18 @@
# resolve at the conventional URL `https://datafusion.apache.org/llms.txt`.
html_extra_path = ["llms.txt"]
+# Base URL of the published site. Required by sphinx-sitemap to build
+# absolute entries in the generated sitemap.xml.
+html_baseurl = "https://datafusion.apache.org/"
+
+# The site is published as a single flat, single-language tree (see
+# .github/workflows/docs.yaml, which rsyncs docs/build/html/ straight to the
+# site root) — there is no /en/ or version path segment. Override
+# sphinx-sitemap's default "{lang}{version}{link}" scheme, which assumes a
+# Read the Docs-style multi-language/multi-version layout, so entries
+# match the real URLs instead of a nonexistent /en/... prefix.
+sitemap_url_scheme = "{link}"
+
html_logo = "_static/images/2x_bgwhite_original.png"
html_css_files = ["theme_overrides.css"]
diff --git a/uv.lock b/uv.lock
index 85fcce1e9db48..240f0e56472a7 100644
--- a/uv.lock
+++ b/uv.lock
@@ -341,6 +341,7 @@ dependencies = [
{ name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "sphinx-reredirects" },
+ { name = "sphinx-sitemap" },
]
[package.metadata]
@@ -352,6 +353,7 @@ requires-dist = [
{ name = "setuptools", specifier = ">=83.0.0,<84" },
{ name = "sphinx", specifier = ">=9,<10" },
{ name = "sphinx-reredirects", specifier = ">=1.1,<2" },
+ { name = "sphinx-sitemap", specifier = ">=2.9.0,<3" },
]
[[package]]
@@ -1037,6 +1039,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" },
]
+[[package]]
+name = "sphinx-last-updated-by-git"
+version = "0.3.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
+ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/03/fd/de1685b6dab173dff31da24e0d3b29f02873fc24a1cdbb7678721ddc8581/sphinx_last_updated_by_git-0.3.8.tar.gz", hash = "sha256:c145011f4609d841805b69a9300099fc02fed8f5bb9e5bcef77d97aea97b7761", size = 10785, upload-time = "2024-08-11T07:15:54.601Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/fb/e496f16fa11fbe2dbdd0b5e306ede153dfed050aae4766fc89d500720dc7/sphinx_last_updated_by_git-0.3.8-py3-none-any.whl", hash = "sha256:6382c8285ac1f222483a58569b78c0371af5e55f7fbf9c01e5e8a72d6fdfa499", size = 8580, upload-time = "2024-08-11T07:15:53.244Z" },
+]
+
[[package]]
name = "sphinx-reredirects"
version = "1.1.0"
@@ -1050,6 +1065,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/51/81/b5dd07067f3daac6d23687ec737b2d593740671ebcd145830c8f92d381c5/sphinx_reredirects-1.1.0-py3-none-any.whl", hash = "sha256:4b5692273c72cd2d4d917f4c6f87d5919e4d6114a752d4be033f7f5f6310efd9", size = 6351, upload-time = "2025-12-22T08:27:59.724Z" },
]
+[[package]]
+name = "sphinx-sitemap"
+version = "2.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx-last-updated-by-git" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/17/56fe0f65e3567f829b2b4153a622be1d4b222b781e0d90d7db5a7738f30f/sphinx_sitemap-2.9.0.tar.gz", hash = "sha256:70f97bcdf444e3d68e118355cf82a1f54c4d3c03d651cd17fe87398b26e25e21", size = 6978, upload-time = "2025-10-06T00:24:00.036Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e4/94/3c57e8b1985e755c48972e2ecd59526d4bf0b52a1fe805bc52a8e98cb92d/sphinx_sitemap-2.9.0-py3-none-any.whl", hash = "sha256:f1f1d3a9ad012ba17a7ef0b560d303bff2d0db26647567d6e810bcc754466664", size = 6218, upload-time = "2025-10-06T00:23:58.778Z" },
+]
+
[[package]]
name = "sphinxcontrib-applehelp"
version = "2.0.0"
From 1cae7f65137712154f02ed56679e4f07ffbd89bc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Dani=C3=ABl=20Heres?=
Date: Thu, 13 Aug 2026 17:27:18 +0000
Subject: [PATCH 02/50] perf(catalog): cut `datafusion-catalog` compile time
~6x (#24325)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Which issue does this PR close?
Adresses: https://github.com/apache/datafusion/issues/13814
First of four; see also #24326 (`datafusion-session`), #24329
(`datafusion` core)
and #24330 (`datafusion-catalog-listing`). All independent — different
crates, so
they can merge in any order.
## Rationale for this change
`datafusion-catalog` is only 4.9k lines of source, but it takes **42s**
of a cold
`cargo build -p datafusion` (measured with `cargo build --timings`).
`-Zself-profile` says ~90% of the crate's compile time is
`evaluate_obligation`,
and ~99% of that is proving `Send`/`Sync`:
| trait | time | goals |
|---|---|---|
| `Send` | 4.04s | 8245 |
| `Sync` | 3.97s | 8195 |
| everything else | 0.03s | 7355 |
51.8s of trait
solving:
| impl | trait solving |
|---|---|
| `MemTable` | 13.7s |
| `StreamTable` | 9.0s |
| `CteWorkTable` | 8.9s |
| `StreamWrite` | 6.9s |
| `StreamTableFactory` | 4.5s |
| `ViewTable` | 4.4s |
| `StreamingTable` | 4.4s |
## What changes are included in this PR?
For those impls, the future is now constructed in a small shim function
that has
**no** where-clauses, so its auto-trait obligations are proved in an
empty
`ParamEnv` and get cached globally. The trait method is left as a
hand-written
desugaring of what `#[async_trait]` would have generated, and only
forwards — it
never creates a coroutine of its own, so it does no auto-trait work.
Isolated probe confirming the shape is what matters (5 trivial impls of
a local
`#[async_trait]` trait taking `&[Expr]`, added to this crate):
| variant | crate build | cost of the 5 impls |
|---|---|---|
| no impls (baseline) | 8.31s | — |
| `#[async_trait]` + `async fn` | 11.64s | +3.33s |
| `async fn` delegating body to a boxed helper | 14.28s | +5.97s |
| desugared signature + boxed shim | 8.01s | ~0 |
Note the middle row: moving only the *body* out makes things worse. The
`async fn`
itself has to go, because its arguments are what the future captures.
## Are these changes tested?
The change is mechanical and the compiler checks each rewritten
signature against
the trait declaration.
Interleaved A/B of `cargo rustc -p datafusion-catalog --lib`,
alternating 3 times
so machine drift cancels out:
```
before: 8.08s 7.90s 7.63s
after: 1.77s 1.70s 1.69s
```
`evaluate_obligation` drops from **7.31s to 70ms**, and its goal count
from
25,811 to 14,934. In a full `cargo build -p datafusion` the crate's unit
goes from
42.3s to ~8s; since it sits alone on the critical path, that time comes
straight
off the build's wall clock.
## Are there any user-facing changes?
No. No public signature changes — after macro expansion the trait
methods have the
same signatures as before.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5
---
datafusion/catalog/src/cte_worktable.rs | 85 +++++++++++----
datafusion/catalog/src/memory/table.rs | 138 ++++++++++++++++++++----
datafusion/catalog/src/stream.rs | 114 ++++++++++++++++++--
datafusion/catalog/src/streaming.rs | 33 +++++-
datafusion/catalog/src/view.rs | 32 +++++-
5 files changed, 353 insertions(+), 49 deletions(-)
diff --git a/datafusion/catalog/src/cte_worktable.rs b/datafusion/catalog/src/cte_worktable.rs
index 5ec688526c92b..37f66b8c56a3e 100644
--- a/datafusion/catalog/src/cte_worktable.rs
+++ b/datafusion/catalog/src/cte_worktable.rs
@@ -18,6 +18,7 @@
//! CteWorkTable implementation used for recursive queries
use std::borrow::Cow;
+use std::future::ready;
use std::sync::Arc;
use arrow::datatypes::SchemaRef;
@@ -26,6 +27,7 @@ use datafusion_common::error::Result;
use datafusion_expr::{Expr, LogicalPlan, TableProviderFilterPushDown, TableType};
use datafusion_physical_plan::ExecutionPlan;
use datafusion_physical_plan::work_table::WorkTableExec;
+use futures::future::BoxFuture;
use crate::{ScanArgs, ScanResult, Session, TableProvider};
@@ -78,30 +80,35 @@ impl TableProvider for CteWorkTable {
TableType::Temporary
}
- async fn scan(
- &self,
- state: &dyn Session,
- projection: Option<&Vec>,
- filters: &[Expr],
+ fn scan<'life0, 'life1, 'life2, 'life3, 'async_trait>(
+ &'life0 self,
+ state: &'life1 dyn Session,
+ projection: Option<&'life2 Vec>,
+ filters: &'life3 [Expr],
limit: Option,
- ) -> Result> {
- let options = ScanArgs::default()
- .with_projection(projection.map(|p| p.as_slice()))
- .with_filters(Some(filters))
- .with_limit(limit);
- Ok(self.scan_with_args(state, options).await?.into_inner())
+ ) -> BoxFuture<'async_trait, Result>>
+ where
+ 'life0: 'async_trait,
+ 'life1: 'async_trait,
+ 'life2: 'async_trait,
+ 'life3: 'async_trait,
+ Self: 'async_trait,
+ {
+ self.scan_boxed(state, projection, filters, limit)
}
- async fn scan_with_args<'a>(
- &self,
- _state: &dyn Session,
+ fn scan_with_args<'a, 'life0, 'life1, 'async_trait>(
+ &'life0 self,
+ state: &'life1 dyn Session,
args: ScanArgs<'a>,
- ) -> Result {
- Ok(ScanResult::new(Arc::new(WorkTableExec::new(
- self.name.clone(),
- Arc::clone(&self.table_schema),
- args.projection().map(|p| p.to_vec()),
- )?)))
+ ) -> BoxFuture<'async_trait, Result>
+ where
+ 'a: 'async_trait,
+ 'life0: 'async_trait,
+ 'life1: 'async_trait,
+ Self: 'async_trait,
+ {
+ Box::pin(ready(self.scan_with_args_inner(state, &args)))
}
fn supports_filters_pushdown(
@@ -115,3 +122,41 @@ impl TableProvider for CteWorkTable {
])
}
}
+
+impl CteWorkTable {
+ fn scan_with_args_inner<'a>(
+ &self,
+ _state: &dyn Session,
+ args: &ScanArgs<'a>,
+ ) -> Result {
+ Ok(ScanResult::new(Arc::new(WorkTableExec::new(
+ self.name.clone(),
+ Arc::clone(&self.table_schema),
+ args.projection().map(|p| p.to_vec()),
+ )?)))
+ }
+
+ fn scan_boxed<'a>(
+ &'a self,
+ state: &'a dyn Session,
+ projection: Option<&'a Vec>,
+ filters: &'a [Expr],
+ limit: Option,
+ ) -> BoxFuture<'a, Result>> {
+ Box::pin(self.scan_inner(state, projection, filters, limit))
+ }
+
+ async fn scan_inner(
+ &self,
+ state: &dyn Session,
+ projection: Option<&Vec>,
+ filters: &[Expr],
+ limit: Option,
+ ) -> Result> {
+ let options = ScanArgs::default()
+ .with_projection(projection.map(|p| p.as_slice()))
+ .with_filters(Some(filters))
+ .with_limit(limit);
+ Ok(self.scan_with_args(state, options).await?.into_inner())
+ }
+}
diff --git a/datafusion/catalog/src/memory/table.rs b/datafusion/catalog/src/memory/table.rs
index 4cf96cb364be8..79c6e44933110 100644
--- a/datafusion/catalog/src/memory/table.rs
+++ b/datafusion/catalog/src/memory/table.rs
@@ -19,6 +19,7 @@
use std::collections::HashMap;
use std::fmt::Debug;
+use std::future::ready;
use std::sync::Arc;
use crate::TableProvider;
@@ -51,6 +52,7 @@ use datafusion_physical_plan::{
use datafusion_session::Session;
use async_trait::async_trait;
+use futures::future::BoxFuture;
use log::debug;
use parking_lot::Mutex;
use tokio::sync::RwLock;
@@ -184,7 +186,95 @@ impl TableProvider for MemTable {
TableType::Base
}
- async fn scan(
+ fn scan<'life0, 'life1, 'life2, 'life3, 'async_trait>(
+ &'life0 self,
+ state: &'life1 dyn Session,
+ projection: Option<&'life2 Vec>,
+ filters: &'life3 [Expr],
+ limit: Option,
+ ) -> BoxFuture<'async_trait, Result>>
+ where
+ 'life0: 'async_trait,
+ 'life1: 'async_trait,
+ 'life2: 'async_trait,
+ 'life3: 'async_trait,
+ Self: 'async_trait,
+ {
+ self.scan_boxed(state, projection, filters, limit)
+ }
+
+ /// Returns an ExecutionPlan that inserts the execution results of a given [`ExecutionPlan`] into this [`MemTable`].
+ ///
+ /// The [`ExecutionPlan`] must have the same schema as this [`MemTable`].
+ ///
+ /// # Arguments
+ ///
+ /// * `state` - The [`SessionState`] containing the context for executing the plan.
+ /// * `input` - The [`ExecutionPlan`] to execute and insert.
+ ///
+ /// # Returns
+ ///
+ /// * A plan that returns the number of rows written.
+ ///
+ /// [`SessionState`]: https://docs.rs/datafusion/latest/datafusion/execution/session_state/struct.SessionState.html
+ fn insert_into<'life0, 'life1, 'async_trait>(
+ &'life0 self,
+ state: &'life1 dyn Session,
+ input: Arc,
+ insert_op: InsertOp,
+ ) -> BoxFuture<'async_trait, Result>>
+ where
+ 'life0: 'async_trait,
+ 'life1: 'async_trait,
+ Self: 'async_trait,
+ {
+ self.insert_into_boxed(state, input, insert_op)
+ }
+
+ fn get_column_default(&self, column: &str) -> Option<&Expr> {
+ self.column_defaults.get(column)
+ }
+
+ fn delete_from<'life0, 'life1, 'async_trait>(
+ &'life0 self,
+ state: &'life1 dyn Session,
+ filters: Vec,
+ ) -> BoxFuture<'async_trait, Result>>
+ where
+ 'life0: 'async_trait,
+ 'life1: 'async_trait,
+ Self: 'async_trait,
+ {
+ self.delete_from_boxed(state, filters)
+ }
+
+ fn update<'life0, 'life1, 'async_trait>(
+ &'life0 self,
+ state: &'life1 dyn Session,
+ assignments: Vec<(String, Expr)>,
+ filters: Vec,
+ ) -> BoxFuture<'async_trait, Result>>
+ where
+ 'life0: 'async_trait,
+ 'life1: 'async_trait,
+ Self: 'async_trait,
+ {
+ self.update_boxed(state, assignments, filters)
+ }
+}
+
+impl MemTable {
+ fn scan_boxed<'a>(
+ &'a self,
+ state: &'a dyn Session,
+ projection: Option<&'a Vec>,
+ filters: &'a [Expr],
+ limit: Option,
+ ) -> BoxFuture<'a, Result>> {
+ Box::pin(self.scan_inner(state, projection, filters, limit))
+ }
+
+ async fn scan_inner(
&self,
state: &dyn Session,
projection: Option<&Vec>,
@@ -225,21 +315,16 @@ impl TableProvider for MemTable {
Ok(DataSourceExec::from_data_source(source))
}
- /// Returns an ExecutionPlan that inserts the execution results of a given [`ExecutionPlan`] into this [`MemTable`].
- ///
- /// The [`ExecutionPlan`] must have the same schema as this [`MemTable`].
- ///
- /// # Arguments
- ///
- /// * `state` - The [`SessionState`] containing the context for executing the plan.
- /// * `input` - The [`ExecutionPlan`] to execute and insert.
- ///
- /// # Returns
- ///
- /// * A plan that returns the number of rows written.
- ///
- /// [`SessionState`]: https://docs.rs/datafusion/latest/datafusion/execution/session_state/struct.SessionState.html
- async fn insert_into(
+ fn insert_into_boxed<'a>(
+ &'a self,
+ state: &'a dyn Session,
+ input: Arc,
+ insert_op: InsertOp,
+ ) -> BoxFuture<'a, Result>> {
+ Box::pin(ready(self.insert_into_inner(state, input, insert_op)))
+ }
+
+ fn insert_into_inner(
&self,
_state: &dyn Session,
input: Arc,
@@ -260,11 +345,15 @@ impl TableProvider for MemTable {
Ok(Arc::new(DataSinkExec::new(input, Arc::new(sink), None)))
}
- fn get_column_default(&self, column: &str) -> Option<&Expr> {
- self.column_defaults.get(column)
+ fn delete_from_boxed<'a>(
+ &'a self,
+ state: &'a dyn Session,
+ filters: Vec,
+ ) -> BoxFuture<'a, Result>> {
+ Box::pin(self.delete_from_inner(state, filters))
}
- async fn delete_from(
+ async fn delete_from_inner(
&self,
state: &dyn Session,
filters: Vec,
@@ -328,7 +417,16 @@ impl TableProvider for MemTable {
Ok(Arc::new(DmlResultExec::new(total_deleted)))
}
- async fn update(
+ fn update_boxed<'a>(
+ &'a self,
+ state: &'a dyn Session,
+ assignments: Vec<(String, Expr)>,
+ filters: Vec,
+ ) -> BoxFuture<'a, Result>> {
+ Box::pin(self.update_inner(state, assignments, filters))
+ }
+
+ async fn update_inner(
&self,
state: &dyn Session,
assignments: Vec<(String, Expr)>,
diff --git a/datafusion/catalog/src/stream.rs b/datafusion/catalog/src/stream.rs
index c8060456dd2a7..f01874612e4d5 100644
--- a/datafusion/catalog/src/stream.rs
+++ b/datafusion/catalog/src/stream.rs
@@ -19,6 +19,7 @@
use std::fmt::Formatter;
use std::fs::{File, OpenOptions};
+use std::future::ready;
use std::io::BufReader;
use std::path::PathBuf;
use std::str::FromStr;
@@ -40,6 +41,7 @@ use datafusion_physical_plan::{DisplayAs, DisplayFormatType, ExecutionPlan};
use async_trait::async_trait;
use futures::StreamExt;
+use futures::future::BoxFuture;
/// A [`TableProviderFactory`] for [`StreamTable`]
#[derive(Debug, Default)]
@@ -47,7 +49,31 @@ pub struct StreamTableFactory {}
#[async_trait]
impl TableProviderFactory for StreamTableFactory {
- async fn create(
+ fn create<'life0, 'life1, 'life2, 'async_trait>(
+ &'life0 self,
+ state: &'life1 dyn Session,
+ cmd: &'life2 CreateExternalTable,
+ ) -> BoxFuture<'async_trait, Result>>
+ where
+ 'life0: 'async_trait,
+ 'life1: 'async_trait,
+ 'life2: 'async_trait,
+ Self: 'async_trait,
+ {
+ self.create_boxed(state, cmd)
+ }
+}
+
+impl StreamTableFactory {
+ fn create_boxed<'a>(
+ &'a self,
+ state: &'a dyn Session,
+ cmd: &'a CreateExternalTable,
+ ) -> BoxFuture<'a, Result>> {
+ Box::pin(ready(self.create_inner(state, cmd)))
+ }
+
+ fn create_inner(
&self,
state: &dyn Session,
cmd: &CreateExternalTable,
@@ -322,7 +348,50 @@ impl TableProvider for StreamTable {
TableType::Base
}
- async fn scan(
+ fn scan<'life0, 'life1, 'life2, 'life3, 'async_trait>(
+ &'life0 self,
+ state: &'life1 dyn Session,
+ projection: Option<&'life2 Vec>,
+ filters: &'life3 [Expr],
+ limit: Option,
+ ) -> BoxFuture<'async_trait, Result>>
+ where
+ 'life0: 'async_trait,
+ 'life1: 'async_trait,
+ 'life2: 'async_trait,
+ 'life3: 'async_trait,
+ Self: 'async_trait,
+ {
+ self.scan_boxed(state, projection, filters, limit)
+ }
+
+ fn insert_into<'life0, 'life1, 'async_trait>(
+ &'life0 self,
+ state: &'life1 dyn Session,
+ input: Arc,
+ insert_op: InsertOp,
+ ) -> BoxFuture<'async_trait, Result>>
+ where
+ 'life0: 'async_trait,
+ 'life1: 'async_trait,
+ Self: 'async_trait,
+ {
+ self.insert_into_boxed(state, input, insert_op)
+ }
+}
+
+impl StreamTable {
+ fn scan_boxed<'a>(
+ &'a self,
+ state: &'a dyn Session,
+ projection: Option<&'a Vec>,
+ filters: &'a [Expr],
+ limit: Option,
+ ) -> BoxFuture<'a, Result>> {
+ Box::pin(ready(self.scan_inner(state, projection, filters, limit)))
+ }
+
+ fn scan_inner(
&self,
state: &dyn Session,
projection: Option<&Vec>,
@@ -351,15 +420,23 @@ impl TableProvider for StreamTable {
)?))
}
- async fn insert_into(
+ fn insert_into_boxed<'a>(
+ &'a self,
+ state: &'a dyn Session,
+ input: Arc,
+ insert_op: InsertOp,
+ ) -> BoxFuture<'a, Result>> {
+ Box::pin(ready(self.insert_into_inner(state, input, insert_op)))
+ }
+
+ fn insert_into_inner(
&self,
- _state: &dyn Session,
+ state: &dyn Session,
input: Arc,
_insert_op: InsertOp,
) -> Result> {
let schema = self.0.source.schema();
- let orders =
- create_lex_ordering(schema, &self.0.order, _state.execution_props())?;
+ let orders = create_lex_ordering(schema, &self.0.order, state.execution_props())?;
// It is sufficient to pass only one of the equivalent orderings:
let ordering = orders.into_iter().next().map(Into::into);
@@ -412,7 +489,30 @@ impl DataSink for StreamWrite {
self.0.source.schema()
}
- async fn write_all(
+ fn write_all<'life0, 'life1, 'async_trait>(
+ &'life0 self,
+ data: SendableRecordBatchStream,
+ context: &'life1 Arc,
+ ) -> BoxFuture<'async_trait, Result>
+ where
+ 'life0: 'async_trait,
+ 'life1: 'async_trait,
+ Self: 'async_trait,
+ {
+ self.write_all_boxed(data, context)
+ }
+}
+
+impl StreamWrite {
+ fn write_all_boxed<'a>(
+ &'a self,
+ data: SendableRecordBatchStream,
+ context: &'a Arc,
+ ) -> BoxFuture<'a, Result> {
+ Box::pin(self.write_all_inner(data, context))
+ }
+
+ async fn write_all_inner(
&self,
mut data: SendableRecordBatchStream,
_context: &Arc,
diff --git a/datafusion/catalog/src/streaming.rs b/datafusion/catalog/src/streaming.rs
index 50f05355aa75e..bb70270ce9157 100644
--- a/datafusion/catalog/src/streaming.rs
+++ b/datafusion/catalog/src/streaming.rs
@@ -17,6 +17,7 @@
//! A simplified [`TableProvider`] for streaming partitioned datasets
+use std::future::ready;
use std::sync::Arc;
use arrow::datatypes::SchemaRef;
@@ -31,6 +32,7 @@ use datafusion_physical_expr::{
};
use datafusion_physical_plan::ExecutionPlan;
use datafusion_physical_plan::streaming::{PartitionStream, StreamingTableExec};
+use futures::future::BoxFuture;
use log::debug;
use crate::{Session, TableProvider};
@@ -121,7 +123,36 @@ impl TableProvider for StreamingTable {
TableType::View
}
- async fn scan(
+ fn scan<'life0, 'life1, 'life2, 'life3, 'async_trait>(
+ &'life0 self,
+ state: &'life1 dyn Session,
+ projection: Option<&'life2 Vec>,
+ filters: &'life3 [Expr],
+ limit: Option,
+ ) -> BoxFuture<'async_trait, Result>>
+ where
+ 'life0: 'async_trait,
+ 'life1: 'async_trait,
+ 'life2: 'async_trait,
+ 'life3: 'async_trait,
+ Self: 'async_trait,
+ {
+ self.scan_boxed(state, projection, filters, limit)
+ }
+}
+
+impl StreamingTable {
+ fn scan_boxed<'a>(
+ &'a self,
+ state: &'a dyn Session,
+ projection: Option<&'a Vec>,
+ filters: &'a [Expr],
+ limit: Option,
+ ) -> BoxFuture<'a, Result>> {
+ Box::pin(ready(self.scan_inner(state, projection, filters, limit)))
+ }
+
+ fn scan_inner(
&self,
state: &dyn Session,
projection: Option<&Vec>,
diff --git a/datafusion/catalog/src/view.rs b/datafusion/catalog/src/view.rs
index 723634c34c0f7..ac0f74c181956 100644
--- a/datafusion/catalog/src/view.rs
+++ b/datafusion/catalog/src/view.rs
@@ -30,6 +30,7 @@ use datafusion_expr::TableType;
use datafusion_expr::{Expr, LogicalPlan};
use datafusion_expr::{LogicalPlanBuilder, TableProviderFilterPushDown};
use datafusion_physical_plan::ExecutionPlan;
+use futures::future::BoxFuture;
/// An implementation of `TableProvider` that uses another logical plan.
#[derive(Debug)]
@@ -95,7 +96,36 @@ impl TableProvider for ViewTable {
Ok(vec![TableProviderFilterPushDown::Exact; filters.len()])
}
- async fn scan(
+ fn scan<'life0, 'life1, 'life2, 'life3, 'async_trait>(
+ &'life0 self,
+ state: &'life1 dyn Session,
+ projection: Option<&'life2 Vec>,
+ filters: &'life3 [Expr],
+ limit: Option,
+ ) -> BoxFuture<'async_trait, Result>>
+ where
+ 'life0: 'async_trait,
+ 'life1: 'async_trait,
+ 'life2: 'async_trait,
+ 'life3: 'async_trait,
+ Self: 'async_trait,
+ {
+ self.scan_boxed(state, projection, filters, limit)
+ }
+}
+
+impl ViewTable {
+ fn scan_boxed<'a>(
+ &'a self,
+ state: &'a dyn Session,
+ projection: Option<&'a Vec>,
+ filters: &'a [Expr],
+ limit: Option,
+ ) -> BoxFuture<'a, Result>> {
+ Box::pin(self.scan_inner(state, projection, filters, limit))
+ }
+
+ async fn scan_inner(
&self,
state: &dyn Session,
projection: Option<&Vec>,
From 345eb4c22dd6d74d416bd42c12bb4dde0f5fbd74 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Dani=C3=ABl=20Heres?=
Date: Thu, 13 Aug 2026 17:27:28 +0000
Subject: [PATCH 03/50] perf(core): cut `datafusion` core compile time ~10x
(#24329)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Which issue does this PR close?
Adresses: https://github.com/apache/datafusion/issues/13814
Third and largest instance of the problem from #24325
(`datafusion-catalog`),
#24326 (`datafusion-session`) and #24330 (`datafusion-catalog-listing`).
Independent of all of them — different crates, so they can merge in any
order.
## Rationale for this change
`datafusion` core is the last unit of a cold `cargo build -p datafusion`
and
compiles alone, so its cost lands directly on the build's wall clock.
**76% of
its compile time was the trait solver**: `-Zself-profile` reported 75.0s
of
`evaluate_obligation` out of 98.6s total, essentially all of it proving
`Send`/`Sync`.
`#[async_trait]` gives each `async fn` a `where 'life0: 'async_trait,
..`
clause. rustc only serves auto-trait obligations from its **global**
evaluation
cache when the `ParamEnv` is empty, so the `Send`/`Sync` proof for
everything
the returned future captures is redone per method. In this crate the
captured
sets include `SessionState`, `&LogicalPlan` and `ListingTableConfig`,
each of
which reaches a large fraction of the logical-plan type graph.
Grouping the goals by the `Self` type in their `ParamEnv` shows how
concentrated
this was — 14 impls, top 10 = 78% of the total:
| impl | trait solving | | impl | trait solving |
|---|---|---|---|---|
| `ParquetReadOptions` | 7.73s | | `DynamicListTableFactory` | 5.18s |
| `JsonReadOptions` | 7.54s | | `ListingTableFactory` | 4.56s |
| `DefaultPhysicalPlanner` | 7.07s | | `TestTableFactory` | 4.47s |
| `CsvReadOptions` | 6.59s | | `ListingTableConfig` | 4.30s |
| `DataFrameTableProvider` | 5.68s | | `DefaultQueryPlanner` | 4.28s |
| *(trait default bodies)* | 5.37s | | `DefaultTableFactory` | 4.15s |
| | | | `SessionState` | 4.11s |
| | | | `ArrowReadOptions` | 3.72s |
For contrast, in the same compile 31,818 goals with an **empty**
`ParamEnv` cost
0.19s in total — 6µs each, against ~1.3ms for the same kind of goal
under
`async_trait`'s bounds.
## What changes are included in this PR?
**First commit.** Each of those methods becomes the hand-written
desugaring of
`async fn`, which only forwards; the coroutine is built in a shim with
no
where-clauses, so its auto-trait obligations are proved in an empty
`ParamEnv`
and land in the global cache. Method bodies are moved verbatim into
inherent fns.
The `ReadOptions` family (25.6s across four impls, plus the 5.37s
default body)
collapses to a **single** proof: all five impls already delegated to the
`_get_resolved_schema` default body, which now hands the coroutine to a
free
`infer_schema_boxed`. Because that helper is a plain function with no
generics
and no where-clauses, its proof is cached once and shared by every impl.
**Second commit**, from re-profiling after the first. 11.15s of trait
solving
remained, in exactly two places:
1. `ListingTableConfigExt::infer` was still an `async fn` capturing
`self: ListingTableConfig` (4.37s). It now uses the same shim as
`infer_options` beside it.
2. `ReadOptions::_get_resolved_schema` still carried `Self: Sync`, which
`#[async_trait]` needed while its body was a coroutine capturing
`&self`.
After the first commit it is neither, so the bound is dead weight — and
it
forced every caller to prove its own type `Sync` structurally, through
arrow's `DataType`/`Schema`, in a non-empty `ParamEnv` (2.1–2.4s each
for
Csv/Json/Parquet; `ArrowReadOptions` was already cheap, having fewer
fields).
Two things worth noting for review:
- `DefaultPhysicalPlanner::create_initial_plan` already used exactly
this shape
(`-> BoxFuture<'a, _>` plus `Box::pin(async move ..)`) — there for
recursion
rather than for compile time. The idiom is not new to this codebase.
- The second commit **relaxes a bound on a public trait method**.
Nothing in tree
overrides `_get_resolved_schema` (all five impls only implement
`get_resolved_schema`) and the underscore prefix marks it as internal,
but an
external override written with `#[async_trait]` would generate `Self:
Sync` and
no longer match. Happy to drop that commit if you would rather not touch
it.
One body became eager: `TestTableFactory::create_inner` has no `.await`,
so it is
a plain fn wrapped in `ready(..)`. It builds a `TestTableProvider` and
has no side
effects. Everything that awaits stays lazy —
`Box::pin(self.m_inner(..))` polls
nothing.
## Are these changes tested?
- `cargo test -p datafusion --lib` — 442 passed
- `cargo check -p datafusion --all-targets` — clean (covers core's
integration
tests and benches, heavy users of these APIs)
- `cargo clippy -p datafusion --lib` — clean
- `cargo doc` with `-D warnings` — clean
- `cargo fmt --check` — clean
The compiler checks each rewritten signature against its trait
declaration, and
every body is moved verbatim.
`cargo rustc -p datafusion --lib` with `-Ztime-passes`, alternated with
the base
so machine drift cancels out:
| | total | `evaluate_obligation` |
|---|---|---|
| base | 88.9s | 75.0s |
| after first commit | 18.6s | 11.15s |
| after second commit | **8.2s** | **234ms** |
234ms over 34,724 goals is 6.7µs each — the same rate as goals that
carry an
empty `ParamEnv`, i.e. the repeated proving is gone rather than merely
reduced.
What remains in this crate is LLVM: 7.6s emitting objects and 4.2s in
LLVM
passes.
An earlier interleaved wall-clock A/B of the first commit alone measured
74.4s/69.9s base against 16.6s/15.5s fixed.
## Are there any user-facing changes?
No, other than the relaxed `Self: Sync` bound described above. No public
signature changes — after macro expansion these methods have the same
signatures
as before.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5
---
datafusion/core/src/dataframe/mod.rs | 32 +++++-
.../core/src/datasource/dynamic_file.rs | 24 ++++-
.../src/datasource/file_format/options.rs | 97 +++++++++++++------
.../core/src/datasource/listing/table.rs | 54 ++++++++---
.../src/datasource/listing_table_factory.rs | 27 +++++-
datafusion/core/src/datasource/provider.rs | 27 +++++-
.../core/src/execution/session_state.rs | 58 +++++++++--
datafusion/core/src/physical_planner.rs | 59 +++++++----
datafusion/core/src/test_util/mod.rs | 30 +++++-
9 files changed, 335 insertions(+), 73 deletions(-)
diff --git a/datafusion/core/src/dataframe/mod.rs b/datafusion/core/src/dataframe/mod.rs
index 325ae91d27bbf..8be23f927edba 100644
--- a/datafusion/core/src/dataframe/mod.rs
+++ b/datafusion/core/src/dataframe/mod.rs
@@ -70,6 +70,7 @@ use datafusion_functions_aggregate::expr_fn::{
use async_trait::async_trait;
use datafusion_catalog::Session;
use datafusion_expr::extension_types::DFArrayFormatterFactory;
+use futures::future::BoxFuture;
/// Contains options that control how data is
/// written out from a DataFrame
@@ -2716,7 +2717,36 @@ impl TableProvider for DataFrameTableProvider {
self.table_type
}
- async fn scan(
+ fn scan<'life0, 'life1, 'life2, 'life3, 'async_trait>(
+ &'life0 self,
+ state: &'life1 dyn Session,
+ projection: Option<&'life2 Vec>,
+ filters: &'life3 [Expr],
+ limit: Option,
+ ) -> BoxFuture<'async_trait, Result>>
+ where
+ 'life0: 'async_trait,
+ 'life1: 'async_trait,
+ 'life2: 'async_trait,
+ 'life3: 'async_trait,
+ Self: 'async_trait,
+ {
+ self.scan_boxed(state, projection, filters, limit)
+ }
+}
+
+impl DataFrameTableProvider {
+ fn scan_boxed<'a>(
+ &'a self,
+ state: &'a dyn Session,
+ projection: Option<&'a Vec>,
+ filters: &'a [Expr],
+ limit: Option,
+ ) -> BoxFuture<'a, Result>> {
+ Box::pin(self.scan_inner(state, projection, filters, limit))
+ }
+
+ async fn scan_inner(
&self,
state: &dyn Session,
projection: Option<&Vec>,
diff --git a/datafusion/core/src/datasource/dynamic_file.rs b/datafusion/core/src/datasource/dynamic_file.rs
index 50ee96da3dff0..f670c9cc444aa 100644
--- a/datafusion/core/src/datasource/dynamic_file.rs
+++ b/datafusion/core/src/datasource/dynamic_file.rs
@@ -31,6 +31,7 @@ use datafusion_common::plan_datafusion_err;
use datafusion_session::SessionStore;
use async_trait::async_trait;
+use futures::future::BoxFuture;
/// [DynamicListTableFactory] is a factory that can create a [ListingTable] from the given url.
#[derive(Default, Debug)]
@@ -53,7 +54,28 @@ impl DynamicListTableFactory {
#[async_trait]
impl UrlTableFactory for DynamicListTableFactory {
- async fn try_new(&self, url: &str) -> Result
All PyGithub classes that implement
CompletableGithubObject support lazy mode (if useful). This
is only useful for classes that have methods creating, changing, or
getting objects.
By default, PyGithub objects are not lazy.
PyGithub objects with a paginated property
The GitHub API has the "feature" of paginated properties.
Some objects returned by the API have a property that allows for
pagination. Fetching subsequent pages of that property means fetching
the entire object (with all other properties) and the specified page of
the paginated property. Iterating over the paginated property means
fetching all other properties multiple times. Fortunately, the allowed
size of each page (per_page is usually 300, in contrast to
the "usual" per_page maximum of 100).
Objects with paginated properties:
Commit.files
Comparison.commits
EnterpriseConsumedLicenses.users
This PR makes iterating those paginated properties use the configured
per_page setting.
Fix getting release by tag in lazy mode
([#3469](https://github.com/pygithub/pygithub/issues/3469)
<https://github.com/PyGithub/PyGithub/pull/3469>)
(7d1ba281e
<https://github.com/PyGithub/PyGithub/commit/7d1ba281e>)
Version 2.9.0 (March 22, 2026)
Notable changes
^^^^^^^^^^^^^^^
Lazy PyGithub objects
"""""""""""""""""""""
The notion of lazy objects has been added to some PyGithub classes in
version 2.6.0.
This release now makes all CompletableGithubObject\s
optionally lazy (if useful).
See [#3403](https://github.com/pygithub/pygithub/issues/3403)
<https://github.com/PyGithub/PyGithub/pull/3403>_ for a
complete list.
In lazy mode, getting a PyGithub object does not send a request to
the GitHub API.
Only accessing methods and properties sends the necessary requests to
the GitHub API:
.. code-block:: python
# Use lazy mode
g = Github(auth=auth, lazy=True)
these method calls do not send requests to the GitHub API
user = g.get_user("PyGithub") # get the user
repo = user.get_repo("PyGithub") # get the user's repo
pull = repo.get_pull(3403) # get a known pull request
issue = pull.as_issue() # turn the pull request into an issue
these method and property calls send requests to Github API
issue.create_reaction("rocket") # create a reaction
created = repo.created_at # get property of lazy object repo
once a lazy object has been fetched, all properties are available
(no more requests)
licence = repo.license
All PyGithub classes that implement
CompletableGithubObject support lazy mode (if useful).
This is only useful for classes that have methods creating, changing, or
getting objects.
By default, PyGithub objects are not lazy.
PyGithub objects with a paginated property
""""""""""""""""""""""""""""""""""""""""""
The GitHub API has the "feature" of paginated
properties.
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
uv.lock | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/uv.lock b/uv.lock
index 240f0e56472a7..751ed96fdb0be 100644
--- a/uv.lock
+++ b/uv.lock
@@ -777,7 +777,7 @@ wheels = [
[[package]]
name = "pygithub"
-version = "2.8.1"
+version = "2.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyjwt", extra = ["crypto"] },
@@ -786,9 +786,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c1/74/e560bdeffea72ecb26cff27f0fad548bbff5ecc51d6a155311ea7f9e4c4c/pygithub-2.8.1.tar.gz", hash = "sha256:341b7c78521cb07324ff670afd1baa2bf5c286f8d9fd302c1798ba594a5400c9", size = 2246994, upload-time = "2025-09-02T17:41:54.674Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ab/c3/8465a311197e16cf5ab68789fe689535e90f6b61ab524cc32a39e67237ae/pygithub-2.9.1.tar.gz", hash = "sha256:59771d7ff63d54d427be2e7d0dad2208dfffc2b0a045fec959263787739b611c", size = 2594989, upload-time = "2026-04-14T07:26:13.622Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/07/ba/7049ce39f653f6140aac4beb53a5aaf08b4407b6a3019aae394c1c5244ff/pygithub-2.8.1-py3-none-any.whl", hash = "sha256:23a0a5bca93baef082e03411bf0ce27204c32be8bfa7abc92fe4a3e132936df0", size = 432709, upload-time = "2025-09-02T17:41:52.947Z" },
+ { url = "https://files.pythonhosted.org/packages/77/aa/81a5506f089a26338bff17535e4339b3b22049ebd1bcdeff756c4d7a7559/pygithub-2.9.1-py3-none-any.whl", hash = "sha256:2ec78fca30092d51a42d76f4ddb02131b6f0c666a35dfdf364cf302cdda115b9", size = 449710, upload-time = "2026-04-14T07:26:12.382Z" },
]
[[package]]
From f71c4f1e5938c496ea66fc4a1b59909fa7375e37 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 14 Aug 2026 06:08:46 +0000
Subject: [PATCH 15/50] chore(deps): bump setuptools from 83.0.0 to 84.0.0
(#24301)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [setuptools](https://github.com/pypa/setuptools) from 83.0.0 to
84.0.0.
Changelog
Newline-separated keywords and
platformspypa/setuptools#4887old
specification <https://peps.python.org/pep-0345/>_
separated items with spaces and the current one uses commas. (#4887)
The C compiler modules now emit log messages through their own
compilers.C.* loggers instead of the distutils root logger,
part of decoupling the compilers package from distutils. The logger
names are normalized to a stable compilers.C.* prefix so
they remain constant as the package migrates toward a standalone
compilers.C distribution. (#5266)
The C compilers gained a Compiler.call method -- a thin
wrapper over subprocess.check_call (with macOS
deployment-target env injection) that is the modern replacement for
Compiler.spawn. The compilers no longer depend on
distutils.spawn, distutils.dir_util,
distutils.file_util, distutils._modified, or
distutils.util.execute/split_quoted: the
generic newer/newer_group and
split_quoted helpers are vendored into the
compilers package, and
Compiler.mkpath/move_file/execute
are implemented directly on the standard library
(os.makedirs/shutil.move). The methods are
retained for backward compatibility. (#5267)
The compilers no longer depend on distutils.util,
distutils.version, distutils.compat, or
distutils._macos_compat. The platform-identification
helpers
(get_platform/get_host_platform/is_mingw)
now live in distutils.compilers.platform.detect and the
macOS deployment-target logic and compiler_fixup in
distutils.compilers.platform.macos;
CygwinCCompiler.gcc_version returns a
packaging.version.Version. distutils.util
re-exports the platform/macOS helpers from their new homes for backward
compatibility rather than keeping duplicate copies. (sysconfig lookups
still route through distutils pending its own decoupling.) (#5268)
The compilers now read their build configuration from the standard
library's sysconfig instead of
distutils.sysconfig. Per-compiler customization --
previously distutils.sysconfig.customize_compiler -- has
moved into Compiler.configure_system(): a no-op on the base
class, with UnixCCompiler applying the
compiler/flag/archiver settings CPython recorded in
sysconfig (and the usual
CC/CFLAGS/LDSHARED/… environment
overrides). distutils.sysconfig.customize_compiler is
retained as a thin wrapper that calls
compiler.configure_system(). (#5269)
Bugfixes
The MSVC linker now passes its arguments through a response file
when the command line would exceed the Windows maximum length, fixing
failures when linking a large number of objects. (#4177)
The Cygwin and MinGW compilers now pass -O1 instead of
a bare -O. The two are equivalent to GCC, but
cc1 rejected the bare form when building 32-bit extensions
with -m32. -- by :user:dchaudhari7177 (#4873)
Setuptools wheels no longer bundled the project's own test modules.
-- by :user:itscloud0 (#5212)
build_ext no longer fails when cross-compiling with a
compiler other than MSVC (such as MinGW). Compiler now
provides a no-op
initialize()pypa/distutils#399
Improved Documentation
Clarified what "correspond exactly to the directory
structure" means in
the packages section of the Package Discovery user guide.
(#4109)
Documented how bdist_wheel's
py_limited_api option controls
abi3 wheel tagging for extension modules -- by
:user:Himanshuagrawal4 (#4741)
Deprecations and Removals
Compiler.spawn is deprecated in favor of the new
Compiler.call. call raises native
subprocess exceptions; spawn remains as a shim
that emits a DeprecationWarning and translates them to
DistutilsExecError. The MSVC spawn
compatibility shim for third-party monkeypatches predating the
env argument (numpy.distutils before 1.19, per pypa/distutils#15)
has been removed. distutils.spawn.spawn is likewise reduced
to a thin wrapper around subprocess.check_call: it no
longer resolves cmd[0] via shutil.which
(subprocess searches PATH itself) nor injects
MACOSX_DEPLOYMENT_TARGET (that now lives with the
compilers, the only callers to which it applied). (#5267)
Building an extension with a MACOSX_DEPLOYMENT_TARGET
lower than the interpreter's configured value now raises
compilers.errors.PlatformError instead of
distutils.errors.DistutilsPlatformError (the macOS
deployment-target check moved into the compilers package).
CygwinCCompiler.gcc_version returns a
packaging.version.Version rather than the removed
distutils.version.LooseVersion. Completing the transition
begun in pypa/distutils#246,
UnixCCompiler.runtime_library_dir_option now returns the
["-Wl,--enable-new-dtags",
"-Wl,-rpath,<dir>"] list directly for GNU ld
rather than collapsing it into a single string, and the temporary
distutils.compat.consolidate_linker_args shim has been
removed. (#5268)
The compilers now define their own exception vocabulary instead of
borrowing distutils' framework errors. Language-agnostic exceptions
(Error, UnknownFileType, and a new
PlatformError) live at
distutils.compilers.errors, leaving room for future
compilers.<language> siblings; the C/C++-specific
CompileError/LinkError/LibError/PreprocessError
remain in distutils.compilers.C.errors. The compilers now
raise compilers.errors.PlatformError where they previously
raised
distutils.errors.DistutilsPlatformError/DistutilsModuleError,
and compilers._modified.newer raises the stdlib
FileNotFoundError. distutils.errors keeps its
own framework exceptions and re-exports the compiler ones
(CCompilerError, CompileError, etc.) for
backward compatibility; because CCompilerError is
compilers.errors.Error, code catching it (as distutils'
top-level handlers do) still catches the new PlatformError.
(#5270)
customize_compiler now asserts that the
compiler-related config variables (CC, CXX,
CFLAGS, etc.) resolve to strings, raising
AssertionError if any are unexpectedly
Nonepypa/distutils#363
Commits
72e919a
Merge pull request #5293
from pypa/bugfix/integration-pip-flit-backend
1b29701
Select the top-level pyproject.toml when reading build requirements
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
docs/pyproject.toml | 2 +-
uv.lock | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/docs/pyproject.toml b/docs/pyproject.toml
index c4edfdcfec38e..48740f2b33308 100644
--- a/docs/pyproject.toml
+++ b/docs/pyproject.toml
@@ -9,6 +9,6 @@ dependencies = [
"myst-parser>=5.1.0,<6",
"maturin>=1.14.1,<2",
"jinja2>=3.1.6,<4",
- "setuptools>=83.0.0,<84",
"sphinx-sitemap>=2.9.0,<3",
+ "setuptools>=84.0.0,<85",
]
diff --git a/uv.lock b/uv.lock
index 751ed96fdb0be..40c632d0fd4ef 100644
--- a/uv.lock
+++ b/uv.lock
@@ -350,7 +350,7 @@ requires-dist = [
{ name = "maturin", specifier = ">=1.14.1,<2" },
{ name = "myst-parser", specifier = ">=5.1.0,<6" },
{ name = "pydata-sphinx-theme", specifier = ">=0.20.0,<1" },
- { name = "setuptools", specifier = ">=83.0.0,<84" },
+ { name = "setuptools", specifier = ">=84.0.0,<85" },
{ name = "sphinx", specifier = ">=9,<10" },
{ name = "sphinx-reredirects", specifier = ">=1.1,<2" },
{ name = "sphinx-sitemap", specifier = ">=2.9.0,<3" },
@@ -943,11 +943,11 @@ wheels = [
[[package]]
name = "setuptools"
-version = "83.0.0"
+version = "84.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
+ { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" },
]
[[package]]
From f2208ff01c0809db65352f23250bea3d6dbe5fe9 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 14 Aug 2026 06:14:17 +0000
Subject: [PATCH 16/50] chore(deps): bump rich from 14.3.2 to 15.0.0 (#24305)
Bumps [rich](https://github.com/Textualize/rich) from 14.3.2 to 15.0.0.
Release notes
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
uv.lock | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/uv.lock b/uv.lock
index 40c632d0fd4ef..364e884b3fce9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -921,15 +921,15 @@ wheels = [
[[package]]
name = "rich"
-version = "14.3.2"
+version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" },
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
From 8ba5b9c98afe62f06cae3d98e390de50c0d82807 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 14 Aug 2026 06:14:38 +0000
Subject: [PATCH 17/50] chore(deps-dev): bump webpack from 5.105.0 to 5.109.2
in /datafusion/wasmtest/datafusion-wasm-app in the all-npm-deps group across
1 directory (#24300)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the all-npm-deps group with 1 update in the
/datafusion/wasmtest/datafusion-wasm-app directory:
[webpack](https://github.com/webpack/webpack).
Updates `webpack` from 5.105.0 to 5.109.2
Release notes
Resolve aliases pointing at a package directory whose name ends with
.js again. (by @alexander-akait
in #21542)
Name CSS sources in source maps by their resource path, without the
css prefix. (by @bjohansebas in
#21536)
Delete no longer referenced files from the filesystem cache directory
after storing the cache, age them by recorded time so restored caches
are cleaned too, and collect every fully expired pack in one store
instead of one per build. (by @bjohansebas in
#21528)
Report "universal" as the loader context
target for the universal target. (by @alexander-akait
in #21540)
Skip require().prop in dead branches gated by inlined
imported constants. (by @hai-x in #21517)
Annotate configuration options and public hooks in the generated
types with the @since JSDoc tag. (by @bjohansebas in
#21473)
v5.109.1
Patch Changes
Fix stray semicolon emitted before an imported call following a
parenthesized sequence element. (by @alexander-akait
in #21533)
Make require(esm)module.exports re-export
analysis independent of module processing order. (by @alexander-akait
in #21521)
Ignore ERR_SERVER_NOT_RUNNING on lazy-compilation backend dispose so
compiler.close() succeeds on Bun. (by @alexander-akait
in #21521)
Name the failing key when DefinePlugin fails to evaluate a
typeof value. (by @alexander-akait
in #21503)
Improve Deno compatibility: guard setNoDelay and
force-close connections on lazy-compilation backend dispose, and return
a real ArrayBuffer from the Node async/sync wasm loader so
WebAssembly.instantiate accepts it. (by @alexander-akait
in #21524)
Speed up the HTML parser and cut its peak memory: module-scope
helpers/state and tokenizer callbacks, plus exact AST column pre-sizing.
(by @alexander-akait
in #21492)
Track CommonJS build dependencies by parsing sources when
require.cache children are unavailable (e.g. Bun). (by @alexander-akait
in #21531)
Cook common string-literal escapes on the JS parser fast path and own
the tokenizer's cold-path readers. (by @alexander-akait
in #21500)
Build the CSS parseA* AST on the SoA store instead of
node classes, cutting parse memory and time. (by @alexander-akait
in #21498)
Speed up and cut memory of the experimental CSS and HTML parsers:
drop two derivable AST node columns, and scan long string, url, comment,
and plaintext token bodies natively. (by @alexander-akait
in #21504)
Speed up non-modules CSS parsing: skip redundant token re-reads, drop
selector-prelude tokens without materializing nodes, allocate rule
preludes lazily, and fast-path empty list seals. (by @alexander-akait
in #21511)
Speed up stats generation and cut its peak memory: reuse cached sort
comparators instead of thrashing the comparator caches on every sort,
and drop redundant module-graph lookups and allocations in the
extractors. (by @alexander-akait
in #21506)
Speed up CSS parsing: byte-range function-name checks, indexed
sibling lookahead. (by @bjohansebas in
#21520)
Reduce allocations and redundant work across the code-generation,
module-concatenation, exports/usage-analysis, hashing, and
chunk-splitting hot paths. (by @alexander-akait
in #21516)
Enable the Node.js compile cache in the webpack CLI entry point. (by
@bjohansebas
in #21523)
Encode the persistent cache with V8's value serializer. (by @avivkeller in #21514)
Resolve aliases pointing at a package directory whose name ends with
.js again. (by @alexander-akait
in #21542)
Name CSS sources in source maps by their resource path, without the
css prefix. (by @bjohansebas in
#21536)
Delete no longer referenced files from the filesystem cache directory
after storing the cache, age them by recorded time so restored caches
are cleaned too, and collect every fully expired pack in one store
instead of one per build. (by @bjohansebas in
#21528)
Report "universal" as the loader context
target for the universal target. (by @alexander-akait
in #21540)
Skip require().prop in dead branches gated by inlined
imported constants. (by @hai-x in #21517)
Annotate configuration options and public hooks in the generated
types with the @since JSDoc tag. (by @bjohansebas in
#21473)
5.109.1
Patch Changes
Fix stray semicolon emitted before an imported call following a
parenthesized sequence element. (by @alexander-akait
in #21533)
Make require(esm)module.exports re-export
analysis independent of module processing order. (by @alexander-akait
in #21521)
Ignore ERR_SERVER_NOT_RUNNING on lazy-compilation backend dispose so
compiler.close() succeeds on Bun. (by @alexander-akait
in #21521)
Name the failing key when DefinePlugin fails to evaluate a
typeof value. (by @alexander-akait
in #21503)
Improve Deno compatibility: guard setNoDelay and
force-close connections on lazy-compilation backend dispose, and return
a real ArrayBuffer from the Node async/sync wasm loader so
WebAssembly.instantiate accepts it. (by @alexander-akait
in #21524)
Speed up the HTML parser and cut its peak memory: module-scope
helpers/state and tokenizer callbacks, plus exact AST column pre-sizing.
(by @alexander-akait
in #21492)
Track CommonJS build dependencies by parsing sources when
require.cache children are unavailable (e.g. Bun). (by @alexander-akait
in #21531)
Cook common string-literal escapes on the JS parser fast path and own
the tokenizer's cold-path readers. (by @alexander-akait
in #21500)
Build the CSS parseA* AST on the SoA store instead of
node classes, cutting parse memory and time. (by @alexander-akait
in #21498)
Speed up and cut memory of the experimental CSS and HTML parsers:
drop two derivable AST node columns, and scan long string, url, comment,
and plaintext token bodies natively. (by @alexander-akait
in #21504)
Speed up non-modules CSS parsing: skip redundant token re-reads, drop
selector-prelude tokens without materializing nodes, allocate rule
preludes lazily, and fast-path empty list seals. (by @alexander-akait
in #21511)
Speed up stats generation and cut its peak memory: reuse cached sort
comparators instead of thrashing the comparator caches on every sort,
and drop redundant module-graph lookups and allocations in the
extractors. (by @alexander-akait
in #21506)
Speed up CSS parsing: byte-range function-name checks, indexed
sibling lookahead. (by @bjohansebas in
#21520)
Reduce allocations and redundant work across the code-generation,
module-concatenation, exports/usage-analysis, hashing, and
chunk-splitting hot paths. (by @alexander-akait
in #21516)
Enable the Node.js compile cache in the webpack CLI entry point. (by
@bjohansebas
in #21523)
Make typing_extensions.TypeAliasType's
__module__ attribute writable. Backport of CPython PR #149172.
Fix setting of __required_keys__ and
__optional_keys__ when inheriting keys with the same
name.
Add support for AsyncIterator, io.Reader,
io.Writer and os.PathLike protocols as bases
for other protocols.
Fix incorrect behaviour on Python 3.9 and Python 3.10 that meant
that calling isinstance with
typing_extensions.Concatenate[...] or
typing_extensions.Unpack[...] as the first argument could
have a different result in some situations depending on whether or not a
profiling function had been set using sys.setprofile. This
affected both CPython and PyPy implementations. Patch by Brian
Schubert.
Fix __init_subclass__() behavior in the presence of
multiple inheritance involving an @deprecated-decorated
base class. Backport of CPython PR #138210
by Brian Schubert.
Raise TypeError when attempting to subclass
typing_extensions.ParamSpec on Python 3.9. The
typing implementation has always raised an error, and the
typing_extensions implementation has raised an error on
Python 3.10+ since typing_extensions v4.6.0. Patch by Brian
Schubert.
Add the bound, covariant,
contravariant, and infer_variance parameters
to TypeVarTuple.
Officially support the bound, covariant,
contravariant and infer_variance parameters to
ParamSpec. Improve the validation of these parameters at
runtime.
Rename typing_extensions.Sentinel to
typing_extensions.sentinel, following the name that has
been adopted for builtins.sentinel on Python 3.15.
typing_extensions.Sentinel is retained as a soft-deprecated
alias for backwards compatibility.
Add support for pickling sentinels.
Sentinels now preserve their identity when copied or
deep-copied.
Deprecate passing name as a keyword argument or
repr as a positional argument to the sentinel
constructor.
The default repr of a sentinel X =
sentinel("X") is now X rather than
<X>.
Deprecate arbitrary attribute assignments to sentinels.
Deprecate subclassing sentinels.
Add support for Python 3.15.
Avoid a DeprecationWarning when deprecated
is applied to a coroutine function on Python 3.14.0.
4.16.0rc2
Changes since 4.16.0rc1:
Avoid a DeprecationWarning when deprecated
is applied to a coroutine function on Python 3.14.0.
Changes since 4.15.0:
Make typing_extensions.TypeAliasType's
__module__ attribute writable. Backport of CPython PR #149172.
Fix setting of __required_keys__ and
__optional_keys__ when inheriting keys with the same
name.
Add support for AsyncIterator, io.Reader,
io.Writer and os.PathLike protocols as bases
for other protocols.
Fix incorrect behaviour on Python 3.9 and Python 3.10 that meant
that calling isinstance with
typing_extensions.Concatenate[...] or
typing_extensions.Unpack[...] as the first argument could
have a different result in some situations depending on whether or not a
profiling function had been set using sys.setprofile. This
affected both CPython and PyPy implementations. Patch by Brian
Schubert.
Fix __init_subclass__() behavior in the presence of
multiple inheritance involving an @deprecated-decorated
base class. Backport of CPython PR #138210
by Brian Schubert.
Raise TypeError when attempting to subclass
typing_extensions.ParamSpec on Python 3.9. The
typing implementation has always raised an error, and the
typing_extensions implementation has raised an error on
Python 3.10+ since typing_extensions v4.6.0. Patch by Brian
Schubert.
Add the bound, covariant,
contravariant, and infer_variance parameters
to TypeVarTuple.
Officially support the bound, covariant,
contravariant and infer_variance parameters to
ParamSpec. Improve the validation of these parameters at
runtime.
Rename typing_extensions.Sentinel to
typing_extensions.sentinel, following the name that has
been adopted for builtins.sentinel on Python 3.15.
typing_extensions.Sentinel is retained as a soft-deprecated
alias for backwards compatibility.
Add support for pickling sentinels.
Sentinels now preserve their identity when copied or
deep-copied.
Deprecate passing name as a keyword argument or
repr as a positional argument to the sentinel
constructor.
The default repr of a sentinel X =
sentinel("X") is now X rather than
<X>.
Deprecate arbitrary attribute assignments to sentinels.
Deprecate subclassing sentinels.
Add support for Python 3.15.
4.16.0rc1
Make typing_extensions.TypeAliasType's
__module__ attribute writable. Backport of CPython PR #149172.
Fix setting of __required_keys__ and
__optional_keys__ when inheriting keys with the same
name.
Avoid a DeprecationWarning when deprecated
is applied to a coroutine function on
Python 3.14.0.
Release 4.16.0rc1 (June 24, 2026)
Make typing_extensions.TypeAliasType's
__module__ attribute writable.
Backport of CPython PR
#149172.
Fix setting of __required_keys__ and
__optional_keys__ when inheriting
keys with the same name.
Add support for AsyncIterator, io.Reader,
io.Writer and os.PathLike protocols
as bases for other protocols.
Fix incorrect behaviour on Python 3.9 and Python 3.10 that meant
that
calling isinstance with
typing_extensions.Concatenate[...] or
typing_extensions.Unpack[...] as the first argument could
have a different
result in some situations depending on whether or not a profiling
function had been
set using sys.setprofile. This affected both CPython and
PyPy implementations.
Patch by Brian Schubert.
Fix __init_subclass__() behavior in the presence of
multiple inheritance involving
an @deprecated-decorated base class. Backport of CPython PR
#138210
by Brian Schubert.
Raise TypeError when attempting to subclass
typing_extensions.ParamSpec on
Python 3.9. The typing implementation has always raised an
error, and the
typing_extensions implementation has raised an error on
Python 3.10+ since
typing_extensions v4.6.0. Patch by Brian Schubert.
Add the bound, covariant,
contravariant, and infer_variance parameters
to TypeVarTuple.
Officially support the bound, covariant,
contravariant and infer_variance
parameters to ParamSpec. Improve the validation of these
parameters at runtime.
Rename typing_extensions.Sentinel to
typing_extensions.sentinel, following the
name that has been adopted for builtins.sentinel on Python
3.15.
typing_extensions.Sentinel is retained as a soft-deprecated
alias for backwards
compatibility.
Add support for pickling sentinels.
Sentinels now preserve their identity when copied or
deep-copied.
Deprecate passing name as a keyword argument or
repr as a positional argument
to the sentinel constructor.
The default repr of a sentinel X =
sentinel("X") is now X rather than
<X>.
Deprecate arbitrary attribute assignments to sentinels.
Moved headers input type back to Mapping
to avoid invariance issues with MutableMapping and inferred
dict types. Users calling Request.headers.update() may need
to narrow typing in their code. (#7441)
Requests 2.34.0 introduces inline types, replacing those provided by
typeshed. Public API types should be fully compatible with mypy,
pyright,
and ty. We believe types are comprehensive but if you find
issues, please
report them to the pinned
tracking issue.
Moved headers input type back to Mapping
to avoid invariance issues
with MutableMapping and inferred dict types. Users calling
Request.headers.update() may need to narrow typing in their
code. (#7441)
2.34.1 (2026-05-13)
Bugfixes
Widened json input type from dict and
list to Mapping
and Sequence. (#7436)
Changed headers input type to MutableMapping and
removed None from
Request.headers typing to improve handling for users. (#7431)
Response.reason moved from str | None to
str to improve handling
for users. (#7437)
Fixed a bug where some bodies with custom __getattr__
implementations
weren't being properly detected as Iterables. (#7433)
2.34.0 (2026-05-11)
Announcements
Requests 2.34.0 introduces inline types, replacing those provided by
typeshed. Public API types should be fully compatible with mypy,
pyright,
and ty. We believe types are comprehensive but if you find issues,
please
report them to the pinned tracking issue.
Response.history no longer contains a reference to
itself, preventing
accidental looping when traversing the history list. (#7328)
Requests no longer performs greedy matching on no_proxy domains. The
proxy_bypass implementation has been updated with CPython's fix from
bpo-39057. (#7427)
Requests no longer incorrectly strips duplicate leading slashes in
URI paths. This should address user issues with specific presigned
URLs. Note the full fix requires urllib3 2.7.0+. (#7315)
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
uv.lock | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/uv.lock b/uv.lock
index c93a17173f12d..61dba57e516d4 100644
--- a/uv.lock
+++ b/uv.lock
@@ -906,7 +906,7 @@ wheels = [
[[package]]
name = "requests"
-version = "2.33.0"
+version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -914,9 +914,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
From dcf54eae2e3fe806c24208d4dd764e63b5047d04 Mon Sep 17 00:00:00 2001
From: anchor
Date: Fri, 14 Aug 2026 06:27:33 +0000
Subject: [PATCH 20/50] docs: fix signum documentation to say zero returns 0
(#24324)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Which issue does this PR close?
N/A — documentation-only correction; no open issue tracks it.
## Rationale for this change
The user guide for `signum` says "Zero and positive numbers return `1`",
but
`signum(0)` returns `0` — the documented return value for zero is the
opposite
of what the function does. The repository's own tests already pin the
real
behavior:
- `datafusion/sqllogictest/test_files/scalar.slt`:
`select signum(-2), signum(0), signum(2);` → `-1 0 1`
- The unit tests in `signum.rs` assert `signum(±0.0) == 0.0`
That behavior is intentional: it matches PostgreSQL `sign()` and Spark
`signum()`, and was changed on purpose in #11580 (issue #11557). Only
the docs
were left describing the pre-#11580 behavior, so this PR updates the
docs, not
the code.
## What changes are included in this PR?
Documentation only:
- Update the `#[user_doc]` description in
`datafusion/functions/src/math/signum.rs`
to state that zero returns `0`.
- Regenerate `docs/source/user-guide/sql/scalar_functions.md` via
`dev/update_function_docs.sh`.
## Are these changes tested?
No new tests: the corrected wording describes behavior the existing
tests above
already assert, and those are what it was checked against.
- `cargo test -p datafusion-functions --lib math::signum` → 2 passed
- `cargo fmt --all -- --check` and `./ci/scripts/doc_prettier_check.sh`
→ clean
## Are there any user-facing changes?
Yes, documentation only. No API or runtime behavior change.
---
Prepared with AI assistance; every claim above was verified locally
against the
repository's existing tests before opening this PR.
Co-authored-by: codeAnqiang-ma <273298913+codeAnqiang-ma@users.noreply.github.com>
---
datafusion/functions/src/math/signum.rs | 3 ++-
docs/source/user-guide/sql/scalar_functions.md | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/datafusion/functions/src/math/signum.rs b/datafusion/functions/src/math/signum.rs
index 05b78fcffe2a7..21b38988c0a01 100644
--- a/datafusion/functions/src/math/signum.rs
+++ b/datafusion/functions/src/math/signum.rs
@@ -34,7 +34,8 @@ use datafusion_macros::user_doc;
doc_section(label = "Math Functions"),
description = r#"Returns the sign of a number.
Negative numbers return `-1`.
-Zero and positive numbers return `1`."#,
+Zero returns `0`.
+Positive numbers return `1`."#,
syntax_example = "signum(numeric_expression)",
standard_argument(name = "numeric_expression", prefix = "Numeric"),
sql_example = r#"```sql
diff --git a/docs/source/user-guide/sql/scalar_functions.md b/docs/source/user-guide/sql/scalar_functions.md
index 1bfec4ce43599..644d42935ec29 100644
--- a/docs/source/user-guide/sql/scalar_functions.md
+++ b/docs/source/user-guide/sql/scalar_functions.md
@@ -796,7 +796,8 @@ round(numeric_expression[, decimal_places])
Returns the sign of a number.
Negative numbers return `-1`.
-Zero and positive numbers return `1`.
+Zero returns `0`.
+Positive numbers return `1`.
```sql
signum(numeric_expression)
From bf568be5abdcb865553c1ed54ecf8b77bbdcc545 Mon Sep 17 00:00:00 2001
From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com>
Date: Fri, 14 Aug 2026 06:38:30 +0000
Subject: [PATCH 21/50] docs: update proto-models README for the
datafusion-common dependency (#24279)
`datafusion-proto-models` now depends on `datafusion-common` and hosts
the `From` / `TryFrom` conversions between the generated proto types and
their `datafusion-common` counterparts, but the crate README still
claimed it had no DataFusion dependencies beyond
`datafusion-proto-common` and exposed only the generated structs.
Describe the conversions and why they live in this crate, and spell out
the narrowness that still holds: `datafusion-common` and
`datafusion-proto-common` are the only DataFusion dependencies.
---------
Co-authored-by: Claude
---
datafusion/proto-models/README.md | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/datafusion/proto-models/README.md b/datafusion/proto-models/README.md
index 34adae9c1aaef..6b3b4ba9a2f49 100644
--- a/datafusion/proto-models/README.md
+++ b/datafusion/proto-models/README.md
@@ -22,9 +22,17 @@
[Apache DataFusion] is an extensible query execution framework, written in Rust, that uses [Apache Arrow] as its in-memory format.
This crate contains the [prost]-generated Rust types for DataFusion's logical
-and physical plan protobuf schemas. It is intentionally kept narrow: it has no
-DataFusion dependencies beyond [`datafusion-proto-common`] and exposes only the
-generated structs (and optional [pbjson]/[serde] support).
+and physical plan protobuf schemas, plus the `From` / `TryFrom` conversions
+between those types and the [`datafusion-common`] types they mirror. The
+conversions live here because their DataFusion side sits _below_ this crate in
+the dependency graph and so cannot host the impls itself — the same arrangement
+[`datafusion-proto-common`] uses for [`ScalarValue`] and [`Statistics`].
+
+It is otherwise intentionally kept narrow: its only DataFusion dependencies are
+[`datafusion-common`] and [`datafusion-proto-common`], and apart from those
+conversions it exposes only the generated structs (and optional
+[pbjson]/[serde] support). In particular it does not depend on
+`datafusion-expr` or on any of the execution crates.
This crate is consumed by [`datafusion-proto`] and may also be depended on
directly by other DataFusion crates that need to refer to the proto schema
@@ -39,5 +47,8 @@ crate, there is no reason to use this crate directly in your project as well.
[prost]: https://docs.rs/prost/latest/prost/
[pbjson]: https://docs.rs/pbjson/latest/pbjson/
[serde]: https://serde.rs/
+[`datafusion-common`]: https://crates.io/crates/datafusion-common
[`datafusion-proto`]: https://crates.io/crates/datafusion-proto
[`datafusion-proto-common`]: https://crates.io/crates/datafusion-proto-common
+[`scalarvalue`]: https://docs.rs/datafusion-common/latest/datafusion_common/scalar/enum.ScalarValue.html
+[`statistics`]: https://docs.rs/datafusion-common/latest/datafusion_common/stats/struct.Statistics.html
From a1864a86e9dbda37aa09e1a40df80d9177f057cf Mon Sep 17 00:00:00 2001
From: dentiny
Date: Fri, 14 Aug 2026 06:42:16 +0000
Subject: [PATCH 22/50] Reduce datafusion-spark dependency footprint (#24351)
## Which issue does this PR close?
N/A
## Rationale for this change
`datafusion-spark` pulls unnecessary crates into its production
dependency graph, increasing compilation time and artifact size for
downstream users.
## What changes are included in this PR?
- Replace compatibility re-exports with the narrower crates that define
`TableFunction` and `FunctionRegistry`.
- Remove the unused `crypto_expressions` activation; Spark provides its
own SHA-1, SHA-2, and CRC32 implementations.
- Fix the `quote` import so builds without the optional `core` feature
continue to compile.
These changes reduce the production dependency graph from 279 to 257
packages. Cargo Machete does not report these dependencies because they
are referenced in source through re-exports or activated through Cargo
features.
## Are these changes tested?
Yes
## Are there any user-facing changes?
No. Public APIs and default behavior remain unchanged.
---
Cargo.lock | 3 +--
datafusion/spark/Cargo.toml | 5 ++---
datafusion/spark/src/function/string/quote.rs | 6 ++++--
datafusion/spark/src/function/table/mod.rs | 2 +-
datafusion/spark/src/lib.rs | 6 +++---
5 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 4cb6117da3fd8..5365be12a1061 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2623,14 +2623,13 @@ dependencies = [
"crc32fast",
"criterion",
"datafusion",
- "datafusion-catalog",
"datafusion-common",
- "datafusion-execution",
"datafusion-expr",
"datafusion-functions",
"datafusion-functions-aggregate",
"datafusion-functions-aggregate-common",
"datafusion-functions-nested",
+ "datafusion-session",
"log",
"num-traits",
"percent-encoding",
diff --git a/datafusion/spark/Cargo.toml b/datafusion/spark/Cargo.toml
index 93987b553f2f5..01fd9ec9b3f5b 100644
--- a/datafusion/spark/Cargo.toml
+++ b/datafusion/spark/Cargo.toml
@@ -49,14 +49,13 @@ chrono = { workspace = true }
crc32fast = "1.4"
# Optional dependency for SessionStateBuilderSpark extension trait
datafusion = { workspace = true, optional = true, default-features = false }
-datafusion-catalog = { workspace = true }
datafusion-common = { workspace = true }
-datafusion-execution = { workspace = true }
datafusion-expr = { workspace = true }
-datafusion-functions = { workspace = true, features = ["crypto_expressions"] }
+datafusion-functions = { workspace = true }
datafusion-functions-aggregate = { workspace = true }
datafusion-functions-aggregate-common = { workspace = true }
datafusion-functions-nested = { workspace = true }
+datafusion-session = { workspace = true }
log = { workspace = true }
num-traits = { workspace = true }
percent-encoding = "2.3.2"
diff --git a/datafusion/spark/src/function/string/quote.rs b/datafusion/spark/src/function/string/quote.rs
index 39ad8bf841764..55d827fb7a280 100644
--- a/datafusion/spark/src/function/string/quote.rs
+++ b/datafusion/spark/src/function/string/quote.rs
@@ -17,12 +17,14 @@
use arrow::array::{ArrayRef, OffsetSizeTrait, StringArray};
use arrow::datatypes::DataType;
-use datafusion::logical_expr::{Coercion, ColumnarValue, Signature, TypeSignatureClass};
use datafusion_common::cast::{as_generic_string_array, as_string_view_array};
use datafusion_common::types::{NativeType, logical_string};
use datafusion_common::utils::take_function_args;
use datafusion_common::{Result, exec_err};
-use datafusion_expr::{ScalarFunctionArgs, ScalarUDFImpl, Volatility};
+use datafusion_expr::{
+ Coercion, ColumnarValue, ScalarFunctionArgs, ScalarUDFImpl, Signature,
+ TypeSignatureClass, Volatility,
+};
use datafusion_functions::utils::make_scalar_function;
use std::sync::Arc;
diff --git a/datafusion/spark/src/function/table/mod.rs b/datafusion/spark/src/function/table/mod.rs
index aba7b7ceb78ea..5ccf8d91df045 100644
--- a/datafusion/spark/src/function/table/mod.rs
+++ b/datafusion/spark/src/function/table/mod.rs
@@ -15,7 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-use datafusion_catalog::TableFunction;
+use datafusion_session::TableFunction;
use std::sync::Arc;
pub mod expr_fn {}
diff --git a/datafusion/spark/src/lib.rs b/datafusion/spark/src/lib.rs
index 6cd4678da7560..c75f94142ff8a 100644
--- a/datafusion/spark/src/lib.rs
+++ b/datafusion/spark/src/lib.rs
@@ -42,7 +42,7 @@
//! Spark functions taking priority.
//!
//! ```
-//! # use datafusion_execution::FunctionRegistry;
+//! # use datafusion_expr::registry::FunctionRegistry;
//! # use datafusion_expr::{ScalarUDF, AggregateUDF, WindowUDF, HigherOrderUDF};
//! # use datafusion_expr::planner::ExprPlanner;
//! # use datafusion_common::Result;
@@ -137,10 +137,10 @@ mod session_state;
#[cfg(feature = "core")]
pub use session_state::SessionStateBuilderSpark;
-use datafusion_catalog::TableFunction;
use datafusion_common::Result;
-use datafusion_execution::FunctionRegistry;
+use datafusion_expr::registry::FunctionRegistry;
use datafusion_expr::{AggregateUDF, ScalarUDF, WindowUDF};
+use datafusion_session::TableFunction;
use log::debug;
use std::sync::Arc;
From 4b620004cf00da6582c547430f636eb90b2934fc Mon Sep 17 00:00:00 2001
From: kosiew
Date: Fri, 14 Aug 2026 08:39:52 +0000
Subject: [PATCH 23/50] Fix ArrowBytesViewMap retained capacity accounting
(#24257)
## Which issue does this PR close?
* Part of #23393
## Rationale for this change
`ArrowBytesViewMap::size()` can underreport memory retained by the map.
It previously omitted the initial allocation of the hash table, counted
`views` by length rather than capacity, counted completed buffers by
used length rather than retained capacity, and did not include the
backing allocation of the `completed` vector.
Because this value is used for memory accounting, it should reflect heap
allocations owned by the map while continuing to exclude `self` and
external input-array buffers.
## What changes are included in this PR?
* Initialize `map_size` from the hash table's initial allocated
capacity.
* Account for `views` and the in-progress buffer using their allocated
sizes.
* Include the allocation backing the `completed` vector.
* Account for each completed Arrow `Buffer` by retained capacity rather
than used length.
* Clarify that `size()` excludes both `self` and input-array buffers.
## Are these changes tested?
Yes. This PR adds:
* `test_size_counts_initial_hash_table_capacity`, which verifies that a
newly created map reports the initial hash table allocation.
* `test_size_counts_retained_buffer_capacities`, which verifies unused
`views` capacity, `completed` vector storage, retained completed-buffer
capacity, and that re-inserting duplicate values does not increase the
reported size.
## Are there any user-facing changes?
No public API or query-result behavior changes. This fixes internal
memory accounting so `ArrowBytesViewMap::size()` more accurately reports
allocations retained by the map.
## LLM-generated code disclosure
This PR includes LLM-generated code and comments. All LLM-generated
content has been manually reviewed.
---
.../src/binary_view_map.rs | 86 +++++++++++++++++--
1 file changed, 80 insertions(+), 6 deletions(-)
diff --git a/datafusion/physical-expr-common/src/binary_view_map.rs b/datafusion/physical-expr-common/src/binary_view_map.rs
index 9d4b556393a24..d05a419c1c93d 100644
--- a/datafusion/physical-expr-common/src/binary_view_map.rs
+++ b/datafusion/physical-expr-common/src/binary_view_map.rs
@@ -155,10 +155,13 @@ where
V: Debug + PartialEq + Eq + Clone + Copy + Default,
{
pub fn new(output_type: OutputType) -> Self {
+ let map = hashbrown::hash_table::HashTable::with_capacity(INITIAL_MAP_CAPACITY);
+ let map_size = map.capacity() * size_of::>();
+
Self {
output_type,
- map: hashbrown::hash_table::HashTable::with_capacity(INITIAL_MAP_CAPACITY),
- map_size: 0,
+ map,
+ map_size,
views: Vec::new(),
in_progress: Vec::new(),
completed: Vec::new(),
@@ -469,11 +472,14 @@ where
}
/// Return the total size, in bytes, of memory used to store the data in
- /// this set, not including `self`
+ /// this set, not including `self` or input-array buffers.
pub fn size(&self) -> usize {
- let views_size = self.views.len() * size_of::();
- let in_progress_size = self.in_progress.capacity();
- let completed_size: usize = self.completed.iter().map(|b| b.len()).sum();
+ // All fields below own their allocations. Count retained capacity rather
+ // than used length because this value drives memory accounting.
+ let views_size = self.views.allocated_size();
+ let in_progress_size = self.in_progress.allocated_size();
+ let completed_size = self.completed.allocated_size()
+ + self.completed.iter().map(Buffer::capacity).sum::();
let nulls_size = self.nulls.allocated_size();
self.map_size
@@ -715,6 +721,74 @@ mod tests {
assert_eq!(set.len(), 10);
}
+ #[test]
+ fn test_size_counts_initial_hash_table_capacity() {
+ let map = ArrowBytesViewMap::<()>::new(OutputType::Utf8View);
+
+ assert_eq!(map.size(), map.map.capacity() * size_of::>());
+ }
+
+ #[test]
+ fn test_size_counts_retained_buffer_capacities() {
+ let first = "a".repeat(BYTE_VIEW_MAX_BLOCK_SIZE / 2 + 1);
+ let second = "b".repeat(BYTE_VIEW_MAX_BLOCK_SIZE / 2 + 1);
+ let third = "c".repeat(BYTE_VIEW_MAX_BLOCK_SIZE / 2 + 1);
+ let values: ArrayRef = Arc::new(StringViewArray::from(vec![
+ first.as_str(),
+ second.as_str(),
+ third.as_str(),
+ ]));
+
+ let mut map = ArrowBytesViewMap::new(OutputType::Utf8View);
+ map.insert_if_new(&values, |_| (), |_| {});
+
+ // Make unused vector capacity explicit; the completed buffers were created
+ // by the map's flush path.
+ map.views.shrink_to_fit();
+ map.views.reserve_exact(1);
+ map.completed.shrink_to_fit();
+ map.completed.reserve_exact(1);
+
+ // The map owns these allocations; `values` and its Arrow buffers remain external.
+ assert!(map.views.capacity() > map.views.len());
+ assert!(map.completed.capacity() > map.completed.len());
+ assert!(
+ map.completed
+ .iter()
+ .any(|buffer| buffer.capacity() > buffer.len())
+ );
+
+ let expected_size = map.map_size
+ + map.views.allocated_size()
+ + map.in_progress.allocated_size()
+ + map.completed.allocated_size()
+ + map.completed.iter().map(Buffer::capacity).sum::()
+ + map.nulls.allocated_size()
+ + map.hashes_buffer.allocated_size();
+ assert_eq!(map.size(), expected_size);
+
+ // Verify the retained-capacity delta independently from the production formula.
+ let legacy_size = map.map_size
+ + map.views.len() * size_of::()
+ + map.in_progress.capacity()
+ + map.completed.iter().map(Buffer::len).sum::()
+ + map.nulls.allocated_size()
+ + map.hashes_buffer.allocated_size();
+ let retained_capacity_delta = (map.views.capacity() - map.views.len())
+ * size_of::()
+ + map.completed.capacity() * size_of::()
+ + map
+ .completed
+ .iter()
+ .map(|buffer| buffer.capacity() - buffer.len())
+ .sum::();
+ assert_eq!(map.size() - legacy_size, retained_capacity_delta);
+
+ let size_after_insert = map.size();
+ map.insert_if_new(&values, |_| (), |_| {});
+ assert_eq!(map.size(), size_after_insert);
+ }
+
#[derive(Debug, PartialEq, Eq, Default, Clone, Copy)]
struct TestPayload {
// store the string value to check against input
From 92f7b1f93e4ccc95f1a3e098d02c1c0fe3cba337 Mon Sep 17 00:00:00 2001
From: Qi Zhu
Date: Fri, 14 Aug 2026 10:43:32 +0000
Subject: [PATCH 24/50] =?UTF-8?q?fix(parquet):=20sync=20rg=5Fplan=20to=20d?=
=?UTF-8?q?ecoder=20frontier=20=E2=80=94=20fix=20wrong=20TopK=20results=20?=
=?UTF-8?q?from=20re-reading=20already-delivered=20row=20groups=20(#24352)?=
=?UTF-8?q?=20(#24354)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Which issue does this PR close?
- Closes #24352.
## Rationale for this change
With `datafusion.execution.parquet.pushdown_filters = true` and TopK
dynamic filter pushdown (both on by default), a query of the shape
`SELECT b FROM t WHERE ORDER BY b LIMIT k` can silently
return **wrong results** — one source row emitted several times and the
true tail of the top-k missing — with no error or warning.
Root cause (thanks to @hhhizzz's very detailed report + fixture in
#24352): a row group whose post-predicate selection is empty is silently
finished by arrow-rs **without handing back a reader**.
`PushDecoderStreamState` pops its `rg_plan` **only** when a reader is
returned, so after a silently-finished RG the plan trails the decoder by
one. When the runtime row-group pruner then rebuilds the decoder
(`into_builder().with_row_groups(...)`) from the stale `rg_plan`, it
re-includes an already-delivered row group, whose rows are emitted a
second time and displace the genuine top-k in the heap.
## What changes are included in this PR?
- `push_decoder.rs`: before each boundary prune/rebuild, `rg_plan` is
synced to the row group the decoder will actually emit next via
`peek_next_row_group()` (`sync_rg_plan_to_decoder_frontier` /
`advance_rg_plan_to`), dropping entries for silently-finished row groups
so a rebuild can never re-include a delivered group. A rebuild frontier
naming an RG not in the plan is now an internal error instead of a
silent plan drain.
## Are these changes tested?
- Adds @hhhizzz's fixture as an slt regression test in
`dynamic_row_group_pruning.slt` (filter column `search_phrase` differs
from the sort column `event_time`, one row group has an empty
post-predicate selection invisible to statistics). It now returns the
correct `p0 p4096 p4097 … p4104` (was the buggy `p0 p4096 p4096 …`).
- clippy clean; `datasource-parquet` unit tests and the sqllogictest
suite pass locally.
## Are there any user-facing changes?
Fixes silently-wrong query results; no API change.
## Note
This is the standalone bug fix extracted from #23696 (per review
discussion in #24352): the same `rg_plan` ↔ decoder-frontier sync, on
its own so it merges fast and is easy to backport. #23696 will rebase on
top so it carries only the fully-matched `RowFilter` skip performance
optimization.
cc @alamb @adriangb @hhhizzz
---
.../parquet/dynamic_row_group_pruning.rs | 115 +++++++++++++++++-
.../datasource-parquet/src/push_decoder.rs | 89 +++++++++++++-
.../test_files/dynamic_row_group_pruning.slt | 84 +++++++++++++
3 files changed, 286 insertions(+), 2 deletions(-)
diff --git a/datafusion/core/tests/parquet/dynamic_row_group_pruning.rs b/datafusion/core/tests/parquet/dynamic_row_group_pruning.rs
index d5d648be9b7aa..917faaaa5ce64 100644
--- a/datafusion/core/tests/parquet/dynamic_row_group_pruning.rs
+++ b/datafusion/core/tests/parquet/dynamic_row_group_pruning.rs
@@ -32,7 +32,7 @@
use std::sync::Arc;
-use arrow::array::{ArrayRef, Int64Array, RecordBatch};
+use arrow::array::{ArrayRef, Int64Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};
use crate::parquet::Unit::RowGroup;
@@ -585,3 +585,116 @@ async fn dynamic_rg_pruning_fires_for_multi_column_sort_leading_tied() {
output.description(),
);
}
+
+/// Build the #24352 fixture: four 2048-row row groups where the filter column
+/// (`search_phrase`) differs from the sort column (`event_time`), and one row
+/// group (the second) has an empty post-predicate selection invisible to
+/// statistics — its only small `event_time` (50) sits on the row whose
+/// `search_phrase` is `''`.
+///
+/// RG 0: event_time = i*1000 (i in 0..2048)
+/// RG 1: i=2048 -> (50, ''), else (20000+i, 'p'||i) (i in 2048..4096)
+/// RG 2: event_time = 100 + (i-4096) (i in 4096..6144)
+/// RG 3: event_time = 5000 + (i-6144) (i in 6144..8192)
+fn build_q26_batches(schema: &Arc) -> Vec {
+ (0..4i64)
+ .map(|rg| {
+ let mut event_time = Vec::with_capacity(2048);
+ let mut search_phrase: Vec = Vec::with_capacity(2048);
+ for j in 0..2048i64 {
+ let i = rg * 2048 + j;
+ let (et, sp) = if i < 2048 {
+ (i * 1000, format!("p{i}"))
+ } else if i < 4096 {
+ if i == 2048 {
+ (50, String::new())
+ } else {
+ (20000 + i, format!("p{i}"))
+ }
+ } else if i < 6144 {
+ (100 + (i - 4096), format!("p{i}"))
+ } else {
+ (5000 + (i - 6144), format!("p{i}"))
+ };
+ event_time.push(et);
+ search_phrase.push(sp);
+ }
+ RecordBatch::try_new(
+ Arc::clone(schema),
+ vec![
+ Arc::new(Int64Array::from(event_time)) as ArrayRef,
+ Arc::new(StringArray::from(search_phrase)) as ArrayRef,
+ ],
+ )
+ .unwrap()
+ })
+ .collect()
+}
+
+/// Regression for #24352: with `pushdown_filters` + TopK dynamic filter, a row
+/// group whose post-predicate selection is empty is silently finished by
+/// arrow-rs without handing back a reader. Before `rg_plan` was synced to the
+/// decoder frontier (`peek_next_row_group`), it trailed the decoder by one, so
+/// a later runtime prune rebuilt the decoder from a stale plan and re-read an
+/// already-delivered row group — the duplicate rows displaced the true top-k.
+#[tokio::test]
+async fn topk_pushdown_does_not_reread_delivered_row_group() {
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("event_time", DataType::Int64, false),
+ Field::new("search_phrase", DataType::Utf8, false),
+ ]));
+ let batches = build_q26_batches(&schema);
+
+ // `RowGroup(2048)` writes one row group per 2048-row batch (4 RGs) and
+ // enables `pushdown_filters`, required for the dynamic filter to reach the
+ // parquet scan.
+ let mut ctx = ContextWithParquet::with_custom_data(
+ Scenario::Int,
+ RowGroup(2048),
+ Arc::clone(&schema),
+ batches,
+ )
+ .await;
+
+ let output = ctx
+ .query(
+ "SELECT search_phrase FROM t \
+ WHERE search_phrase <> '' ORDER BY event_time LIMIT 10",
+ )
+ .await;
+
+ // `search_phrase` is unique per row, so any repeated value is the same
+ // source row emitted twice. The correct answer is the 10 smallest-
+ // `event_time` non-empty phrases, matching DuckDB / pushdown-off.
+ assert_eq!(output.result_rows, 10, "{}", output.description());
+
+ // The test must actually exercise the runtime prune/rebuild path that
+ // caused #24352 (not just a happy-path scan), otherwise a future default or
+ // optimizer change could let it pass without the bug's precondition. Assert
+ // the dynamic filter pruned at least one row group.
+ let pruned = output
+ .row_groups_pruned_dynamic_filter()
+ .expect("`row_groups_pruned_dynamic_filter` metric must be registered");
+ assert!(
+ pruned >= 1,
+ "test must exercise dynamic RG pruning (the #24352 path); pruned={pruned}\n{}",
+ output.description(),
+ );
+
+ let formatted = output.pretty_results();
+ for p in [
+ "p0", "p4096", "p4097", "p4098", "p4099", "p4100", "p4101", "p4102", "p4103",
+ "p4104",
+ ] {
+ assert!(
+ formatted.contains(&format!("| {p} ")),
+ "missing {p} from top-k; got:\n{formatted}",
+ );
+ }
+ // The bug emitted p4096 twice (and dropped p4101..=p4104); assert no dup.
+ assert_eq!(
+ formatted.matches("| p4096 ").count(),
+ 1,
+ "p4096 emitted more than once — rg_plan/decoder desync; got:\n{formatted}",
+ );
+}
diff --git a/datafusion/datasource-parquet/src/push_decoder.rs b/datafusion/datasource-parquet/src/push_decoder.rs
index 14904bada2cfc..74d8997198872 100644
--- a/datafusion/datasource-parquet/src/push_decoder.rs
+++ b/datafusion/datasource-parquet/src/push_decoder.rs
@@ -53,7 +53,7 @@ use parquet::arrow::async_reader::AsyncFileReader;
use parquet::arrow::push_decoder::{ParquetPushDecoder, ParquetPushDecoderBuilder};
use parquet::file::metadata::ParquetMetaData;
-use datafusion_common::{DataFusionError, Result};
+use datafusion_common::{DataFusionError, Result, internal_err};
use datafusion_physical_expr::expressions::DynamicFilterTracking;
use datafusion_physical_expr_common::physical_expr::PhysicalExpr;
use datafusion_physical_plan::metrics::{BaselineMetrics, Count, Gauge};
@@ -342,6 +342,20 @@ impl PushDecoderStreamState {
.as_ref()
.expect("decoder present")
.is_at_row_group_boundary();
+ // Only the runtime pruner rebuilds the decoder from `rg_plan`, so
+ // only it needs `rg_plan` kept in sync with the decoder frontier.
+ // arrow-rs silently finishes row groups whose post-predicate
+ // selection is empty without handing back a reader, so without this
+ // sync `rg_plan` trails the decoder by one and a rebuild re-reads an
+ // already-delivered row group (#24352). Gating on the pruner also
+ // avoids the O(remaining row groups) cost of `peek_next_row_group()`
+ // on ordinary scans that never rebuild.
+ if at_boundary
+ && self.row_group_pruner.is_some()
+ && let Err(e) = self.sync_rg_plan_to_decoder_frontier()
+ {
+ return Some((Err(e), self));
+ }
if at_boundary && !self.rg_plan.is_empty() {
let mut pruned_count = 0usize;
if let Some(pruner) = self.row_group_pruner.as_mut() {
@@ -414,6 +428,51 @@ impl PushDecoderStreamState {
}
}
+ /// Keep `rg_plan.front()` aligned with the row group the decoder will emit
+ /// next. `try_next_reader` silently finishes row groups whose post-predicate
+ /// selection is empty (no reader handed back), which would otherwise leave
+ /// `rg_plan` trailing the decoder by one — a later prune/rebuild would then
+ /// re-include an already-delivered row group (#24352).
+ fn sync_rg_plan_to_decoder_frontier(&mut self) -> Result<()> {
+ match self
+ .decoder
+ .as_ref()
+ .expect("decoder present")
+ .peek_next_row_group()
+ .map_err(DataFusionError::from)?
+ {
+ Some(actual) => Self::advance_rg_plan_to(&mut self.rg_plan, actual)?,
+ // Decoder has nothing left to emit — drain our plan so the stream
+ // finishes cleanly.
+ None => self.rg_plan.clear(),
+ }
+ Ok(())
+ }
+
+ /// Pop entries off `rg_plan` until its front is `target`.
+ ///
+ /// `target` is the RG the decoder will emit next and must still be in the
+ /// plan. A missing `target` means the decoder's frontier and `rg_plan` have
+ /// diverged; we surface that as an internal error rather than silently
+ /// draining the plan, which would truncate the scan. Kept free-standing on
+ /// `rg_plan` (rather than `&mut self`) so the pop/guard logic is
+ /// unit-testable without constructing a full stream state.
+ fn advance_rg_plan_to(
+ rg_plan: &mut VecDeque,
+ target: usize,
+ ) -> Result<()> {
+ while let Some(front) = rg_plan.front() {
+ if front.rg_index == target {
+ return Ok(());
+ }
+ rg_plan.pop_front();
+ }
+ internal_err!(
+ "push decoder frontier RG {target} is not in rg_plan; \
+ decoder and plan have diverged"
+ )
+ }
+
/// Copies metrics from ArrowReaderMetrics (the metrics collected by the
/// arrow-rs parquet reader) to the parquet file metrics for DataFusion
fn copy_arrow_reader_metrics(&self) {
@@ -607,4 +666,32 @@ mod tests {
assert!(!pruner.should_prune(&[1]));
assert!(!pruner.should_prune(&[2]));
}
+
+ #[test]
+ fn advance_rg_plan_to_pops_up_to_target() {
+ let mut plan: VecDeque = [0usize, 1, 2, 3]
+ .into_iter()
+ .map(|rg_index| RgPlanEntry { rg_index })
+ .collect();
+ PushDecoderStreamState::advance_rg_plan_to(&mut plan, 2).unwrap();
+ assert_eq!(
+ plan.iter().map(|e| e.rg_index).collect::>(),
+ vec![2, 3],
+ "must pop the entries before `target` and stop at it",
+ );
+ }
+
+ #[test]
+ fn advance_rg_plan_to_errors_when_target_absent() {
+ let mut plan: VecDeque = [0usize, 1, 2]
+ .into_iter()
+ .map(|rg_index| RgPlanEntry { rg_index })
+ .collect();
+ let err = PushDecoderStreamState::advance_rg_plan_to(&mut plan, 5)
+ .expect_err("a target absent from the plan must be an internal error");
+ assert!(
+ err.to_string().contains("diverged"),
+ "expected a divergence internal error, got: {err}",
+ );
+ }
}
diff --git a/datafusion/sqllogictest/test_files/dynamic_row_group_pruning.slt b/datafusion/sqllogictest/test_files/dynamic_row_group_pruning.slt
index 2149cacfc0a55..81d9511839725 100644
--- a/datafusion/sqllogictest/test_files/dynamic_row_group_pruning.slt
+++ b/datafusion/sqllogictest/test_files/dynamic_row_group_pruning.slt
@@ -110,3 +110,87 @@ RESET datafusion.execution.parquet.pushdown_filters;
statement ok
RESET datafusion.explain.analyze_level;
+
+# Regression test for #24352: TopK dynamic filter + `pushdown_filters` must not
+# re-read an already-delivered row group. The filter column (`search_phrase`)
+# differs from the sort column (`event_time`), and one row group has an empty
+# post-predicate selection that row-group statistics cannot see — its only small
+# `event_time` (50) sits on the row where `search_phrase = ''`. arrow-rs finishes
+# that RG without handing back a reader; without syncing `rg_plan` to the decoder
+# frontier via `peek_next_row_group`, `rg_plan` trailed the decoder by one, so a
+# later runtime prune rebuilt the decoder from a stale plan and re-read an
+# already-delivered RG — duplicating rows and dropping the true top-k tail.
+statement ok
+set datafusion.execution.parquet.pushdown_filters = true;
+
+statement ok
+set datafusion.execution.target_partitions = 1;
+
+# Both dynamic-filter switches are on by default; set them explicitly so this
+# test keeps exercising the prune/rebuild path even if the defaults change.
+statement ok
+set datafusion.optimizer.enable_dynamic_filter_pushdown = true;
+
+statement ok
+set datafusion.optimizer.enable_topk_dynamic_filter_pushdown = true;
+
+statement ok
+CREATE TABLE q26_src AS
+SELECT
+ CAST(CASE
+ WHEN i < 2048 THEN i * 1000
+ WHEN i < 4096 THEN (CASE WHEN i = 2048 THEN 50 ELSE 20000 + i END)
+ WHEN i < 6144 THEN 100 + (i - 4096)
+ ELSE 5000 + (i - 6144)
+ END AS BIGINT) AS event_time,
+ CASE WHEN i = 2048 THEN '' ELSE 'p' || CAST(i AS VARCHAR) END AS search_phrase
+FROM generate_series(0, 8191) AS t(i);
+
+statement ok
+COPY (SELECT * FROM q26_src)
+TO 'test_files/scratch/dynamic_row_group_pruning/q26.parquet'
+STORED AS PARQUET
+OPTIONS ('format.max_row_group_size' '2048');
+
+statement ok
+drop table q26_src;
+
+statement ok
+CREATE EXTERNAL TABLE q26 (event_time BIGINT NOT NULL, search_phrase VARCHAR NOT NULL)
+STORED AS PARQUET
+LOCATION 'test_files/scratch/dynamic_row_group_pruning/q26.parquet';
+
+# Each search_phrase is unique, so any repeated value would be the same source
+# row emitted twice. The result must be the 10 smallest-`event_time` non-empty
+# phrases with no duplicates (matches DuckDB and pushdown-off DataFusion).
+query T
+SELECT search_phrase FROM q26 WHERE search_phrase <> '' ORDER BY event_time LIMIT 10;
+----
+p0
+p4096
+p4097
+p4098
+p4099
+p4100
+p4101
+p4102
+p4103
+p4104
+
+statement ok
+drop table q26;
+
+statement ok
+RESET datafusion.execution.parquet.pushdown_filters;
+
+# The SLT runner sets `target_partitions` to 4 instead of using the default, so
+# restore it explicitly rather than RESET (which would revert to the system
+# default = num_cpus and leak modified config out of this file).
+statement ok
+set datafusion.execution.target_partitions = 4;
+
+statement ok
+RESET datafusion.optimizer.enable_dynamic_filter_pushdown;
+
+statement ok
+RESET datafusion.optimizer.enable_topk_dynamic_filter_pushdown;
From aacf2ce571cd10521e671a26a9a599577f05c1ed Mon Sep 17 00:00:00 2001
From: Tim Saucer
Date: Fri, 14 Aug 2026 11:31:36 +0000
Subject: [PATCH 25/50] fix: correct list field inner type in array functions
(#24345)
## Which issue does this PR close?
- Closes #24341
## Rationale for this change
There is a regression from 54.1.0 to 55.0.0rc2 where the spark function
`slice` can fail as demonstrated in the updated unit test, and as
described in the connected issue.
## What changes are included in this PR?
In the nested functions, get the inner field directly from the input for
array slicing operations. Also, since it is possible the inner list
could be non-nullable emit an empty slice instead of a null child
element for the outer lists's nulls.
## Are these changes tested?
Added 6 new tests in the SLT suite.
## Are there any user-facing changes?
None
---
datafusion/functions-nested/src/extract.rs | 36 ++++++++++++++-----
.../test_files/array/array_pop.slt | 33 +++++++++++++++++
.../test_files/array/array_slice.slt | 31 ++++++++++++++++
.../test_files/spark/array/slice.slt | 6 ++++
4 files changed, 97 insertions(+), 9 deletions(-)
diff --git a/datafusion/functions-nested/src/extract.rs b/datafusion/functions-nested/src/extract.rs
index 9d367f0161fdd..8f2ea1f40dcb2 100644
--- a/datafusion/functions-nested/src/extract.rs
+++ b/datafusion/functions-nested/src/extract.rs
@@ -23,9 +23,8 @@ use arrow::array::{
};
use arrow::buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
use arrow::datatypes::DataType;
-use arrow::datatypes::{
- DataType::{FixedSizeList, LargeList, LargeListView, List, ListView, Null},
- Field,
+use arrow::datatypes::DataType::{
+ FixedSizeList, LargeList, LargeListView, List, ListView, Null,
};
use datafusion_common::cast::as_large_list_array;
use datafusion_common::cast::as_list_array;
@@ -622,9 +621,23 @@ where
let values = array.values();
let original_data = values.to_data();
let capacity = Capacities::Array(original_data.len());
+ // Carry the input's list field through to the output so that the returned
+ // type matches the one promised by `return_type` / `return_field_from_args`,
+ // including the field name, nullability and metadata.
+ let field = match array.data_type() {
+ List(field) | LargeList(field) => Arc::clone(field),
+ other => {
+ return internal_err!(
+ "general_array_slice got unexpected data type: {other}"
+ );
+ }
+ };
+ // `use_nulls` is false because we never call `try_extend_nulls`: null rows are
+ // emitted as empty slices. Arrow still allocates a validity buffer on its own
+ // if the child array has nulls.
let mut mutable =
- MutableArrayData::with_capacities(vec![&original_data], true, capacity);
+ MutableArrayData::with_capacities(vec![&original_data], false, capacity);
// We have the slice syntax compatible with DuckDB v0.8.1.
// The rule `adjusted_from_index` and `adjusted_to_index` follows the rule of array_slice in duckdb.
@@ -638,9 +651,11 @@ where
let end = offset_window[1];
let len = end - start;
+ // The row is null, so its contents are never observed. Emit an empty
+ // slice rather than a null child element: the input's list field may be
+ // non-nullable, in which case a null child would be invalid.
if nulls.as_ref().is_some_and(|n| n.is_null(row_index)) {
- mutable.try_extend_nulls(1)?;
- offsets.push(offsets[row_index] + O::usize_as(1));
+ offsets.push(offsets[row_index]);
continue;
}
@@ -682,7 +697,7 @@ where
let data = mutable.freeze();
Ok(Arc::new(GenericListArray::::try_new(
- Arc::new(Field::new_list_field(array.value_type(), true)),
+ field,
OffsetBuffer::::new(offsets.into()),
arrow::array::make_array(data),
nulls,
@@ -704,12 +719,15 @@ where
let field = match array.data_type() {
ListView(field) | LargeListView(field) => Arc::clone(field),
other => {
- return internal_err!("array_slice got unexpected data type: {}", other);
+ return internal_err!(
+ "general_list_view_array_slice got unexpected data type: {other}"
+ );
}
};
+ // See the note on `use_nulls` in `general_array_slice`.
let mut mutable =
- MutableArrayData::with_capacities(vec![&original_data], true, capacity);
+ MutableArrayData::with_capacities(vec![&original_data], false, capacity);
// We must build `offsets` and `sizes` buffers manually as ListView does not enforce
// monotonically increasing offsets.
diff --git a/datafusion/sqllogictest/test_files/array/array_pop.slt b/datafusion/sqllogictest/test_files/array/array_pop.slt
index a72e566b9e7ab..0b7ebf75f4b2b 100644
--- a/datafusion/sqllogictest/test_files/array/array_pop.slt
+++ b/datafusion/sqllogictest/test_files/array/array_pop.slt
@@ -318,5 +318,38 @@ select array_pop_front(arrow_cast([1, 2], 'LargeListView(Int64)'));
----
[2]
+# maintains inner nullability
+query ??TT
+select
+ array_pop_front(column1),
+ array_pop_back(column1),
+ arrow_typeof(array_pop_front(column1)),
+ arrow_typeof(array_pop_back(column1))
+from values
+ (arrow_cast([], 'List(non-null Int32)')),
+ (arrow_cast(NULL, 'List(non-null Int32)')),
+ (arrow_cast([1, 3, 5, -5], 'List(non-null Int32)'))
+;
+----
+[] [] List(non-null Int32) List(non-null Int32)
+NULL NULL List(non-null Int32) List(non-null Int32)
+[3, 5, -5] [1, 3, 5] List(non-null Int32) List(non-null Int32)
+
+query ??TT
+select
+ array_pop_front(column1),
+ array_pop_back(column1),
+ arrow_typeof(array_pop_front(column1)),
+ arrow_typeof(array_pop_back(column1))
+from values
+ (arrow_cast([], 'LargeList(non-null Int32)')),
+ (arrow_cast(NULL, 'LargeList(non-null Int32)')),
+ (arrow_cast([1, 3, 5, -5], 'LargeList(non-null Int32)'))
+;
+----
+[] [] LargeList(non-null Int32) LargeList(non-null Int32)
+NULL NULL LargeList(non-null Int32) LargeList(non-null Int32)
+[3, 5, -5] [1, 3, 5] LargeList(non-null Int32) LargeList(non-null Int32)
+
include ./cleanup.slt.part
diff --git a/datafusion/sqllogictest/test_files/array/array_slice.slt b/datafusion/sqllogictest/test_files/array/array_slice.slt
index 14587a50b2266..76b81b28efc58 100644
--- a/datafusion/sqllogictest/test_files/array/array_slice.slt
+++ b/datafusion/sqllogictest/test_files/array/array_slice.slt
@@ -450,6 +450,37 @@ NULL
NULL
[1, 3, 5]
+# maintains inner nullability
+query ?T
+select array_slice(column1, 2, 3), arrow_typeof(array_slice(column1, 2, 3))
+from values
+ (arrow_cast([], 'List(non-null Int32)')),
+ (arrow_cast(NULL, 'List(non-null Int32)')),
+ (arrow_cast([1, 3, 5, -5], 'List(non-null Int32)'))
+;
+----
+[] List(non-null Int32)
+NULL List(non-null Int32)
+[3, 5] List(non-null Int32)
+
+query ?T
+select array_slice(column1, 2, 3), arrow_typeof(array_slice(column1, 2, 3))
+from values
+ (arrow_cast([], 'LargeList(non-null Int32)')),
+ (arrow_cast(NULL, 'LargeList(non-null Int32)')),
+ (arrow_cast([1, 3, 5, -5], 'LargeList(non-null Int32)'))
+;
+----
+[] LargeList(non-null Int32)
+NULL LargeList(non-null Int32)
+[3, 5] LargeList(non-null Int32)
+
+query ?T
+select array_slice(column1, 2, 3, 2), arrow_typeof(array_slice(column1, 2, 3, 2))
+from values (arrow_cast([1, 3, 5, -5], 'List(non-null Int32)'));
+----
+[3] List(non-null Int32)
+
# Testing with empty arguments should result in an error
query error DataFusion error: Error during planning: 'array_slice' does not support zero arguments
select array_slice();
diff --git a/datafusion/sqllogictest/test_files/spark/array/slice.slt b/datafusion/sqllogictest/test_files/spark/array/slice.slt
index aaf4aa4909dfd..f6fb431a0769b 100644
--- a/datafusion/sqllogictest/test_files/spark/array/slice.slt
+++ b/datafusion/sqllogictest/test_files/spark/array/slice.slt
@@ -152,3 +152,9 @@ query ?
SELECT slice(make_array(1), 3, 4)
----
[]
+
+# the inner field name of the input list is preserved
+query ?T
+SELECT slice(array(1, 2, 3, 4), 2, 2), arrow_typeof(slice(array(1, 2, 3, 4), 2, 2));
+----
+[2, 3] List(Int64, field: 'element')
From b08666c4967c220f49784fb9da076e88e15c173e Mon Sep 17 00:00:00 2001
From: Subham Singhal
Date: Fri, 14 Aug 2026 12:32:09 +0000
Subject: [PATCH 26/50] feat(pwmj): support LeftSemi/LeftAnti existence joins
via classic scan (#23870)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Which issue does this close?
Part of #17427 (Make `PiecewiseMergeJoin` work in DataFusion). Adds
`LeftSemi` / `LeftAnti` support, one of the epic's checklist items.
Supersedes the stale #18392, taking the alternative approach that
@2010YOUY01 suggested there (reuse the classic join path for generality)
rather than a dedicated existence stream.
## Rationale for this change
An inequality-correlated `EXISTS` / `NOT EXISTS` (e.g. `WHERE EXISTS
(SELECT 1 FROM r WHERE l.x < r.y)`) has no equi-key, so it decorrelates
to a `LeftSemi` / `LeftAnti` join with a single range predicate. Today
`PiecewiseMergeJoinExec` rejects existence joins (`not_impl_err!`) and
these queries fall back to `NestedLoopJoinExec`, which is O(n*m).
Microbenchmark (20K × 20K rows, single inequality,
`enable_piecewise_merge_join`
on vs off), added in this PR as `piecewise_merge_join_semi_anti`:
| Case | PWMJ | NestedLoopJoin | Speedup |
|------------------------------|----------|----------------|---------|
| LeftSemi, high selectivity | ~0.50 ms | ~75 ms | ~150× |
| LeftAnti, high selectivity | ~0.44 ms | ~75 ms | ~170× |
| LeftSemi, low selectivity | ~0.48 ms | ~76 ms | ~158× |
| LeftAnti, low selectivity | ~0.48 ms | ~75 ms | ~155× |
## What changes are included in this PR?
**Existence joins (`LeftSemi` / `LeftAnti`) for `PiecewiseMergeJoin`:**
- Route `LeftSemi` / `LeftAnti` with a single range predicate to
`PiecewiseMergeJoinExec` in the physical planner (still gated behind
`enable_piecewise_merge_join`, default `false`).
- Reuse the classic scan: on the first match, mark the matching
buffered-row suffix in the visited bitmap and emit from it in the final
pass (`LeftSemi` = marked rows, `LeftAnti` = unmarked; NULL join keys
are never marked, so they are correctly excluded from Semi and included
in Anti). Only left-side columns are produced.
- `RightSemi` / `RightAnti` / `Mark` remain unsupported (they require
swapping the inputs); they are still rejected in `try_new` and excluded
in the planner. Left as a follow-up.
- Two small optimizations so existence marking stays O(buffered), not
O(n*m): stop scanning a batch after the first match (the marked suffix
is maximal), and a cross-batch low-water mark so later batches only mark
not-yet-marked rows.
- Updated the operator docstring to describe the implemented
classic-reuse path and keep the min/max fast-path as a documented
follow-up.
## Are these changes tested?
Yes.
- 33 unit tests in `classic_join.rs` covering `LeftSemi` / `LeftAnti`
across `<`, `<=`, `>`, `>=`; NULL join keys; all-null streamed side;
empty inputs; `Date32` keys; multi-batch and multi-partition streamed
inputs; and the low-water-mark skip branch.
- End-to-end SLT coverage in `pwmj.slt` for `EXISTS` / `NOT EXISTS`
(including NULLs) with `EXPLAIN` assertions confirming the plan uses
`PiecewiseMergeJoin`.
- The multi-partition test also guards the final-pass counter fix.
## Are there any user-facing changes?
No behaviour change by default: `enable_piecewise_merge_join` remains
`false`. When enabled, single-range-predicate `LeftSemi` / `LeftAnti`
joins are planned as `PiecewiseMergeJoin` instead of `NestedLoopJoin`.
No API changes.
---
datafusion/core/src/physical_planner.rs | 8 +-
datafusion/core/tests/fuzz_cases/join_fuzz.rs | 223 ++++-
.../src/joins/piecewise_merge_join/exec.rs | 77 +-
.../piecewise_merge_join/existence_join.rs | 819 +++++++++++++++++
.../src/joins/piecewise_merge_join/mod.rs | 1 +
.../src/joins/piecewise_merge_join/utils.rs | 14 +-
datafusion/sqllogictest/test_files/pwmj.slt | 822 ++++++++++++++++++
7 files changed, 1939 insertions(+), 25 deletions(-)
create mode 100644 datafusion/physical-plan/src/joins/piecewise_merge_join/existence_join.rs
diff --git a/datafusion/core/src/physical_planner.rs b/datafusion/core/src/physical_planner.rs
index 6e68ed9ce69b3..6e50ec8aa4298 100644
--- a/datafusion/core/src/physical_planner.rs
+++ b/datafusion/core/src/physical_planner.rs
@@ -1602,11 +1602,13 @@ impl DefaultPhysicalPlanner {
Arc::new(CrossJoinExec::new(physical_left, physical_right))
} else if num_range_filters == 1
&& total_filters == 1
+ // PWMJ supports classic joins and Left Semi/Anti existence joins.
+ // Right Semi/Anti and Mark joins are not implemented yet (they
+ // would require swapping the inputs so the marked side is buffered),
+ // so exclude them here and let them fall back to NestedLoopJoin.
&& !matches!(
join_type,
- JoinType::LeftSemi
- | JoinType::RightSemi
- | JoinType::LeftAnti
+ JoinType::RightSemi
| JoinType::RightAnti
| JoinType::LeftMark
| JoinType::RightMark
diff --git a/datafusion/core/tests/fuzz_cases/join_fuzz.rs b/datafusion/core/tests/fuzz_cases/join_fuzz.rs
index 81c7c9f83928e..fce6999fd0d77 100644
--- a/datafusion/core/tests/fuzz_cases/join_fuzz.rs
+++ b/datafusion/core/tests/fuzz_cases/join_fuzz.rs
@@ -34,18 +34,24 @@ use datafusion::physical_plan::collect;
use datafusion::physical_plan::expressions::Column;
use datafusion::physical_plan::joins::utils::{ColumnIndex, JoinFilter};
use datafusion::physical_plan::joins::{
- HashJoinExec, NestedLoopJoinExec, PartitionMode, SortMergeJoinExec,
+ HashJoinExec, NestedLoopJoinExec, PartitionMode, PiecewiseMergeJoinExec,
+ SortMergeJoinExec,
};
+use datafusion::physical_plan::sorts::sort::SortExec;
+use datafusion::physical_plan::{ExecutionPlan, ExecutionPlanProperties, common};
use datafusion::prelude::{SessionConfig, SessionContext};
use datafusion_common::{NullEquality, ScalarValue};
+use datafusion_common_runtime::SpawnedTask;
use datafusion_execution::TaskContext;
use datafusion_execution::disk_manager::{DiskManagerBuilder, DiskManagerMode};
use datafusion_execution::runtime_env::RuntimeEnvBuilder;
use datafusion_physical_expr::PhysicalExprRef;
use datafusion_physical_expr::expressions::Literal;
+use datafusion_physical_expr_common::sort_expr::{LexOrdering, PhysicalSortExpr};
use itertools::Itertools;
use rand::Rng;
+use rand::{SeedableRng, rngs::StdRng};
use test_utils::stagger_batch_with_seed;
// Determines what Fuzz tests needs to run
@@ -1347,3 +1353,218 @@ fn make_staggered_batches_binary(
// preserve your existing randomized partitioning
stagger_batch_with_seed(batch, 42)
}
+
+// ---- Differential fuzz: PiecewiseMergeJoin existence joins vs NestedLoopJoin ----
+//
+// `PiecewiseMergeJoinExec` takes a range predicate and no equi keys, so it cannot be added to
+// `JoinFuzzTestCase` above (that harness joins on `a` and `b` and folds the equality into the
+// NestedLoopJoin filter). These tests use `NestedLoopJoinExec` with an equivalent filter as
+// the oracle instead.
+//
+// What only randomization reaches: `LeftSemi`/`LeftAnti` record matches in a shared
+// `AtomicUsize` watermark and emit once, from whichever streamed partition finishes last. The
+// streamed side below is spread round-robin over several partitions as one-row batches, so
+// batches arrive in an order no static test pins down, and the counter that gates the final
+// pass is seeded from a partition count that deliberately disagrees with the `num_partitions`
+// argument.
+
+fn pwmj_kv_schema() -> Arc {
+ Arc::new(Schema::new(vec![
+ arrow::datatypes::Field::new("id", arrow::datatypes::DataType::Int32, false),
+ arrow::datatypes::Field::new("k", arrow::datatypes::DataType::Int32, true),
+ ]))
+}
+
+fn pwmj_kv_batch(ids: &[i32], keys: &[Option]) -> RecordBatch {
+ RecordBatch::try_new(
+ pwmj_kv_schema(),
+ vec![
+ Arc::new(Int32Array::from(ids.to_vec())),
+ Arc::new(Int32Array::from(keys.to_vec())),
+ ],
+ )
+ .unwrap()
+}
+
+/// Single-partition, single-batch input: the buffered side, and the oracle's probe side.
+fn pwmj_single_exec(ids: &[i32], keys: &[Option]) -> Arc {
+ MemorySourceConfig::try_new_exec(
+ &[vec![pwmj_kv_batch(ids, keys)]],
+ pwmj_kv_schema(),
+ None,
+ )
+ .unwrap()
+}
+
+/// Streamed side spread round-robin across `nparts` partitions, one row per batch.
+fn pwmj_parts_exec(
+ ids: &[i32],
+ keys: &[Option],
+ nparts: usize,
+) -> Arc {
+ let nparts = nparts.max(1);
+ let mut partitions: Vec> = vec![Vec::new(); nparts];
+ for (row, (&id, key)) in ids.iter().zip(keys.iter()).enumerate() {
+ partitions[row % nparts].push(pwmj_kv_batch(&[id], &[*key]));
+ }
+ for p in partitions.iter_mut() {
+ if p.is_empty() {
+ p.push(pwmj_kv_batch(&[], &[]));
+ }
+ }
+ MemorySourceConfig::try_new_exec(&partitions, pwmj_kv_schema(), None).unwrap()
+}
+
+fn pwmj_existence_plan(
+ left: Arc,
+ right: Arc,
+ op: Operator,
+ join_type: JoinType,
+) -> Arc {
+ // Matches `PiecewiseMergeJoinExec::required_input_ordering`: descending for `<`/`<=`,
+ // ascending for `>`/`>=`, NULLs first either way.
+ let sort_options = match op {
+ Operator::Lt | Operator::LtEq => SortOptions::new(true, true),
+ Operator::Gt | Operator::GtEq => SortOptions::new(false, true),
+ other => panic!("not a range operator: {other:?}"),
+ };
+ let ordering = LexOrdering::new(vec![PhysicalSortExpr::new(
+ Arc::new(Column::new("k", 1)),
+ sort_options,
+ )])
+ .unwrap();
+ let sorted_left = Arc::new(SortExec::new(ordering, left));
+ let on: (PhysicalExprRef, PhysicalExprRef) =
+ (Arc::new(Column::new("k", 1)), Arc::new(Column::new("k", 1)));
+ // `num_partitions` is 1 while the streamed side has up to 3: the final-pass counter must
+ // come from the streamed side's partition count, not from this argument.
+ Arc::new(
+ PiecewiseMergeJoinExec::try_new(sorted_left, right, on, op, join_type, 1)
+ .unwrap(),
+ )
+}
+
+fn pwmj_nlj_oracle_plan(
+ left: Arc,
+ right: Arc,
+ op: Operator,
+ join_type: JoinType,
+) -> Arc {
+ let intermediate_schema = Schema::new(vec![
+ arrow::datatypes::Field::new("k", arrow::datatypes::DataType::Int32, true),
+ arrow::datatypes::Field::new("k", arrow::datatypes::DataType::Int32, true),
+ ]);
+ let expr = Arc::new(BinaryExpr::new(
+ Arc::new(Column::new("k", 0)),
+ op,
+ Arc::new(Column::new("k", 1)),
+ )) as PhysicalExprRef;
+ let column_indices = vec![
+ ColumnIndex {
+ index: 1,
+ side: JoinSide::Left,
+ },
+ ColumnIndex {
+ index: 1,
+ side: JoinSide::Right,
+ },
+ ];
+ let filter = JoinFilter::new(expr, column_indices, Arc::new(intermediate_schema));
+ Arc::new(
+ NestedLoopJoinExec::try_new(left, right, Some(filter), &join_type, None).unwrap(),
+ )
+}
+
+/// Executes every output partition concurrently and returns the surviving left `id`s, sorted.
+///
+/// Concurrent rather than one partition at a time: the partitions share the watermark and race
+/// to be the one that runs the final pass, which is the part a sequential drain cannot reach.
+async fn pwmj_collect_ids(
+ plan: Arc,
+ task_ctx: Arc,
+) -> Vec {
+ let streams = (0..plan.output_partitioning().partition_count())
+ .map(|partition| plan.execute(partition, Arc::clone(&task_ctx)).unwrap())
+ .collect::>();
+ let per_partition =
+ futures::future::join_all(streams.into_iter().map(|stream| {
+ SpawnedTask::spawn(async move { common::collect(stream).await })
+ }))
+ .await;
+
+ let mut ids = Vec::new();
+ for batches in per_partition {
+ for batch in batches.unwrap().unwrap() {
+ let col = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::()
+ .unwrap();
+ ids.extend((0..col.len()).map(|i| col.value(i)));
+ }
+ }
+ ids.sort();
+ ids
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn fuzz_pwmj_existence_matches_nested_loop() {
+ // A small batch size splits the final-pass output across several coalesced batches even
+ // for these tiny inputs, covering that boundary too.
+ let task_ctx = Arc::new(
+ TaskContext::default()
+ .with_session_config(SessionConfig::new().with_batch_size(3)),
+ );
+ let ops = [Operator::Lt, Operator::LtEq, Operator::Gt, Operator::GtEq];
+
+ for seed in 0..60u64 {
+ let mut rng = StdRng::seed_from_u64(seed);
+ let left_len = rng.random_range(0..25usize);
+ let right_len = rng.random_range(0..25usize);
+ // A narrow key range forces duplicates and equal-boundary cases.
+ let key_range = rng.random_range(1..6i32);
+ let nparts = rng.random_range(1..4usize);
+
+ let gen_keys = |n: usize, rng: &mut StdRng| -> Vec