Skip to content

Reuse Subplan with multiple references [Part 1 / 3] - #22675

Closed
nathanb9 wants to merge 7 commits into
apache:mainfrom
nathanb9:cte-materialization-pr1
Closed

nathanb9 wants to merge 7 commits into
apache:mainfrom
nathanb9:cte-materialization-pr1

Conversation

@nathanb9

@nathanb9 nathanb9 commented May 31, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Multi-referenced CTEs currently recompute their body for each reference. This PR adds infrastructure to compute them once, cache results, and share across all consumers.

The nodes are deliberately CTE-agnostic (per @Tpt / @Dandandan on #22676 / #8777): they are keyed by a unique SubplanId, not a CTE name, so a future CommonSubplanEliminate rule can reuse them to materialize any duplicated subplan. CTEs are just the first caller.

What changes are included in this PR?

Logical Nodes (datafusion-expr)

pub struct SubplanId(pub u64);   // unique; binds a producer to its readers + keys the cache

pub struct MaterializedSubplanProducer {
    pub id: SubplanId,
    pub name: String,            // display/debug only (not unique under nesting)
    pub plan: Arc<LogicalPlan>,
    pub continuation: Arc<LogicalPlan>,
    pub schema: DFSchemaRef,
    pub force_materialized: bool,
}

pub struct MaterializedSubplanReader { pub id: SubplanId, pub name: String, pub schema: DFSchemaRef }

Physical Operators (datafusion-physical-plan)

pub struct MaterializedSubplanCache { /* OnceAsync<MaterializedBatches> */ }
pub struct MaterializedBatches { partitions: Vec<Vec<RecordBatch>>, reservation: MemoryReservation }

pub struct MaterializedSubplanExec { ... }       // materializes + runs continuation
pub struct MaterializedSubplanReaderExec { ... } // replays the shared cache

Extension Planner (datafusion-core)

pub struct MaterializedSubplanPlanner { ... }    // caches keyed by SubplanId

SQL Planner

  • Wraps all multi-ref CTEs in Producer/Reader nodes when enable_materialized_ctes = true
  • Skips cheap non-volatile CTEs (literals, empty relations)
  • Respects MATERIALIZED / NOT MATERIALIZED SQL hints

Config

datafusion.execution.enable_materialized_ctes = false (default, opt-in for now)

Review feedback addressed

  • Cache keyed by SubplanId, not CTE name — names are not unique under nesting (two sibling WITH t ...); the id makes the producer↔reader binding unambiguous.
  • MemoryPool accounting — the buffered result reserves memory via a MemoryReservation (released on drop), so the operator respects configured limits. (Spill is still follow-up.)
  • Statistics & trait parity with ScalarSubqueryExec — partition_statistics delegates to the continuation; added maintains_input_order / benefits_from_input_partitioning / cardinality_effect.
  • Reader partitioning — readers now expose the materialized partition count 1:1 (no stats-based coalescing); downstream distribution is handled by EnforceDistribution.
  • Wired execution metrics; removed dead ref-count code.

Follow-up PRs will add:

  • InlineCte optimizer rule (smart inlining heuristic)
  • CteFilterPusher optimizer rule (OR-combined filter pushdown)
  • Spill-to-disk for large materialized results
  • Then enable by default?

Are these changes tested?

Tests cover materialization, partition preservation, cache isolation, volatile function semantics, statistics propagation, sibling same-name CTE isolation, and materialize-vs-inline result equivalence.

Are there any user-facing changes?

Yes. New config flag datafusion.execution.enable_materialized_ctes and SQL hint support (AS MATERIALIZED / AS NOT MATERIALIZED). Disabled by default.

@github-actions github-actions Bot added documentation Improvements or additions to documentation sql SQL Planner logical-expr Logical plan and expressions core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) common Related to common crate physical-plan Changes to the physical-plan crate labels May 31, 2026
@github-actions

github-actions Bot commented May 31, 2026 •

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion v53.1.0 (current)
       Built [  96.541s] (current)
     Parsing datafusion v53.1.0 (current)
      Parsed [   0.034s] (current)
    Building datafusion v53.1.0 (baseline)
       Built [  95.364s] (baseline)
     Parsing datafusion v53.1.0 (baseline)
      Parsed [   0.036s] (baseline)
    Checking datafusion v53.1.0 -> v53.1.0 (no change; assume patch)
     Checked [   0.632s] 222 checks: 222 pass, 30 skip
     Summary no semver update required
    Finished [ 194.226s] datafusion
    Building datafusion-common v53.1.0 (current)
       Built [  33.226s] (current)
     Parsing datafusion-common v53.1.0 (current)
      Parsed [   0.057s] (current)
    Building datafusion-common v53.1.0 (baseline)
       Built [  33.197s] (baseline)
     Parsing datafusion-common v53.1.0 (baseline)
      Parsed [   0.059s] (baseline)
    Checking datafusion-common v53.1.0 -> v53.1.0 (no change; assume patch)
     Checked [   0.652s] 222 checks: 221 pass, 1 fail, 0 warn, 30 skip

--- failure constructible_struct_adds_field: externally-constructible struct adds field ---

Description:
A pub struct constructible with a struct literal has a new pub field. Existing struct literals must be updated to include the new field.
        ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.47.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field ExecutionOptions.enable_materialized_ctes in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:469

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  68.422s] datafusion-common
    Building datafusion-expr v53.1.0 (current)
       Built [  26.169s] (current)
     Parsing datafusion-expr v53.1.0 (current)
      Parsed [   0.074s] (current)
    Building datafusion-expr v53.1.0 (baseline)
       Built [  26.206s] (baseline)
     Parsing datafusion-expr v53.1.0 (baseline)
      Parsed [   0.074s] (baseline)
    Checking datafusion-expr v53.1.0 -> v53.1.0 (no change; assume patch)
     Checked [   1.135s] 222 checks: 222 pass, 30 skip
     Summary no semver update required
    Finished [  54.707s] datafusion-expr
    Building datafusion-physical-plan v53.1.0 (current)
       Built [  36.611s] (current)
     Parsing datafusion-physical-plan v53.1.0 (current)
      Parsed [   0.126s] (current)
    Building datafusion-physical-plan v53.1.0 (baseline)
       Built [  36.247s] (baseline)
     Parsing datafusion-physical-plan v53.1.0 (baseline)
      Parsed [   0.126s] (baseline)
    Checking datafusion-physical-plan v53.1.0 -> v53.1.0 (no change; assume patch)
     Checked [   0.591s] 222 checks: 222 pass, 30 skip
     Summary no semver update required
    Finished [  74.995s] datafusion-physical-plan
    Building datafusion-sql v53.1.0 (current)
       Built [  40.829s] (current)
     Parsing datafusion-sql v53.1.0 (current)
      Parsed [   0.031s] (current)
    Building datafusion-sql v53.1.0 (baseline)
       Built [  40.710s] (baseline)
     Parsing datafusion-sql v53.1.0 (baseline)
      Parsed [   0.032s] (baseline)
    Checking datafusion-sql v53.1.0 -> v53.1.0 (no change; assume patch)
     Checked [   0.228s] 222 checks: 222 pass, 30 skip
     Summary no semver update required
    Finished [  83.027s] datafusion-sql
    Building datafusion-sqllogictest v53.1.0 (current)
       Built [ 165.681s] (current)
     Parsing datafusion-sqllogictest v53.1.0 (current)
      Parsed [   0.022s] (current)
    Building datafusion-sqllogictest v53.1.0 (baseline)
       Built [ 165.110s] (baseline)
     Parsing datafusion-sqllogictest v53.1.0 (baseline)
      Parsed [   0.024s] (baseline)
    Checking datafusion-sqllogictest v53.1.0 -> v53.1.0 (no change; assume patch)
     Checked [   0.086s] 222 checks: 222 pass, 30 skip
     Summary no semver update required
    Finished [ 333.774s] datafusion-sqllogictest

@github-actions github-actions Bot added the auto detected api change Auto detected API change label May 31, 2026
@nathanb9 nathanb9 changed the title feat: CTE materialization for multi-referenced CTEs Cache and materalize CTE with multiple references May 31, 2026
@nathanb9 nathanb9 changed the title Cache and materalize CTE with multiple references Cache and materalize CTE with multiple references [Part 1 / 3] May 31, 2026
@nathanb9
nathanb9 marked this pull request as ready for review May 31, 2026 17:52
@nathanb9
nathanb9 marked this pull request as draft June 2, 2026 12:23

@neilconway neilconway left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it's possible to reuse some of the existing machinery for some of this. For example, the work of filling a scratch buffer and then having a scan node read from it is already handled by WorkTable / WorkTableExec -- I wonder if we could reuse that for the "reader" side of things.

Comment on lines +221 to +225
fn partition_statistics(&self, _partition: Option<usize>) -> Result<Arc<Statistics>> {
Ok(Arc::new(Statistics::new_unknown(
&self.continuation.schema(),
)))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why new_unknown?

Comment on lines +76 to +93
/// Physical execution plan that materializes a CTE and then executes
/// a continuation plan. The CTE results are cached in a shared
/// `MaterializedCteCache` for use by `MaterializedCteReaderExec` nodes.
#[derive(Debug)]
pub struct MaterializedCteExec {
/// Name of the CTE
name: String,
/// The plan that computes the CTE
cte_plan: Arc<dyn ExecutionPlan>,
/// The continuation plan that uses the materialized CTE
continuation: Arc<dyn ExecutionPlan>,
/// Shared cache for the CTE results
cache: Arc<MaterializedCteCache>,
/// Execution metrics
metrics: ExecutionPlanMetricsSet,
/// Cache holding plan properties
properties: Arc<PlanProperties>,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't reuse ScalarSubqueryExec, but there are some similarities between what that node is trying to do and what we're trying to do here. It might be worth comparing the two implementations and making them consistent, where appropriate.

For example, the way SSE does maintains_input_order, partition_statistics, and cardinality_effect are all probably worth adopting here. For benefits_from_input_partitioning, we probably want to say that the CTE might benefit from partitioning but the continuation does not?

Comment on lines +46 to +47
/// Map of CTE name to shared cache
caches: Mutex<HashMap<String, Arc<MaterializedCteCache>>>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CTE names might not be unique, given nesting. For example:

SELECT a.cnt AS acnt, b.cnt AS bcnt
FROM
  (WITH t AS (SELECT column1 AS a FROM foo WHERE column1 <= 2)
   SELECT count(*) cnt FROM t x JOIN t y ON x.a = y.a) a,
  (WITH t AS (SELECT column1 AS a FROM foo WHERE column1 >= 4)
   SELECT count(*) cnt FROM t x JOIN t y ON x.a = y.a) b

@nathanb9 nathanb9 changed the title Cache and materalize CTE with multiple references [Part 1 / 3] Reuse Subplan with multiple references [Part 1 / 3] Jun 13, 2026
@github-actions

Copy link
Copy Markdown

Thank you for your contribution. Unfortunately, this pull request is stale because it has been open 60 days with no activity. Please remove the stale label or comment or this will be closed in 7 days.

@github-actions github-actions Bot added the Stale PR has not had any activity for some time label Aug 13, 2026
@github-actions github-actions Bot closed this Aug 21, 2026
Theodus pushed a commit to Theodus/datafusion that referenced this pull request Oct 15, 2026
## Which issue does this PR close?

- Closes apache#17737.
- Related: apache#8777, apache#22676, apache#10337.

## Rationale for this change

DataFusion inlines every CTE reference. When a query references the same
CTE several times, the CTE body is planned and executed once for each
reference. Postgres lets users opt out of this with `WITH x AS
MATERIALIZED (...)`. `sqlparser` already parses the directive into
`Cte.materialized`, but the SQL planner ignores it.

Earlier attempts (apache#22551, apache#22675) bundled automatic materialization,
heuristics and filter pushdown. This PR is limited to the explicit
directive, as proposed in
apache#17737 (comment):

- No config flag.
- No heuristic.
- Queries without `MATERIALIZED` plan exactly as before.

Deciding automatically when to materialize can be discussed separately.

Example:

```sql
WITH r AS MATERIALIZED (SELECT random() AS v)
SELECT a.v = b.v FROM r a, r b;
-- true: the body runs once. With NOT MATERIALIZED (or no directive) each reference evaluates random() separately.
```

## What changes are included in this PR?

- **Planner:** `datafusion/sql/src/cte.rs` and `query.rs` plan a
`MATERIALIZED` CTE as a `MaterializedCte` node, whose children are the
CTE body and the rest of the query, plus one `MaterializedCteScan` leaf
for each reference. These are `UserDefinedLogicalNode`s in
`datafusion-expr`.
  - `NOT MATERIALIZED` and the default keep today's inlining.
- `MATERIALIZED` also applies in a `WITH RECURSIVE` block. A recursive
CTE is still planned as `RecursiveQuery`, and its reference to itself
stays a work table scan.
- `head_output_expr`, `PullUpCorrelatedExpr` and `PushDownFilter` look
through `MaterializedCte` to its continuation, so `IN` / `EXISTS`
subqueries and filters from an outer query work as with an inlined CTE.
The body is never filtered.
- Each occurrence of a CTE in a plan gets its own id before physical
planning, so a view that is referenced two times runs its CTE two times,
as in PostgreSQL.
- **Physical plan:** `MaterializedCteExec` and `MaterializedCteScanExec`
in `datafusion-physical-plan`, and a physical planner pass that binds
each scan to its producer.
- **Execution:** the body is run to completion once, all body partitions
in parallel, on the first poll of any scan. It is started by one shared
future and runs in a `JoinSet` that is aborted when dropped. Every scan
then replays the buffered batches.
- **Why full buffering:** a bounded streaming fan-out deadlocks when,
for example, both sides of one hash join read the same CTE (see apache#8777).
This design cannot deadlock.
- **Memory:** buffered batches are tracked by a `MemoryReservation`
created with `can_spill(true)`. When the reservation cannot grow, the
rest of that partition is written to a spill file through
`SpillManager`. `SpillFile::read_stream` can be called by any number of
readers at the same time, so every scan replays both the in-memory
batches and the spill files. Scans read spill files without read-ahead,
and after buffering one decoded batch per concurrent reader is reserved.
If the pool cannot grant this, in-memory batches are moved to spill
files until it can. Each scan reserves memory for the largest batch on
disk, so an in-memory batch that is larger than the batches on disk is
written in pieces, and a move is made only when it releases more memory
than it adds to the scans.
apache#25910 adds a byte bound to
spill files with a better size model; when it merges, this operator can
use it and drop its own slicing. A body that reads an unbounded source
is rejected at planning time, because it would be buffered forever.

### Notes on earlier review feedback (apache#22675)

- **CTE names are not unique:** each scan binds to its producer through
an id allocated by the planner, not through the CTE name. Sibling
subqueries that declare CTEs with the same name are tested.
- **Alignment with `ScalarSubqueryExec`:** `MaterializedCteExec` follows
the same shape. The rest of the query is its output and passes through
input order, partitioning and statistics. A scan inside a scalar
subquery can start the body itself (tested).
- **Why not `WorkTable`:** `WorkTable` gives its batches to exactly one
consumer (`take()`) and returns an error when memory runs out;
`RecursiveQueryExec` does not spill. A materialized CTE needs N readers
and spilling. The scan does follow the same pattern as `WorkTableExec`:
a leaf that reads a handle bound at planning time.
- **Statistics:** the scans report unknown statistics for now. A leaf
cannot ask its producer for statistics through `StatisticsContext`. I
would like to do this in a follow-up. This is a known limitation that
can make a plan worse than the plan of the inlined CTE, not only leave
it the same: `JoinSelection` does not know that a scan is small, and can
put the other, larger input of a hash join on the build side (pinned in
`cte_materialized.slt`).

### Out of scope, possible follow-ups

Tracked under the EPIC apache#22676:

- Protobuf serialization. These nodes have no proto encoding yet, so
serializing a plan that contains them fails in the default extension
codec (apache#26231).
- Statistics, ordering and partitioning on the scans (apache#26230).
- Release the buffer when the last scan finishes rather than when the
plan is dropped (apache#26233).
- Byte-bounded spill files (apache#26232).
- Keep the output per run, so that `reset_state` follows its contract
(apache#26229).
- Deciding automatically when to materialize (apache#26237).
- First-class `LogicalPlan` variants (apache#26238).
- Pushing filters that all references share into the CTE body (apache#26239).
- Pruning unused columns from the CTE body (apache#26240).
- `sqlparser` parses `[NOT] MATERIALIZED` only in `PostgreSqlDialect`,
so the tests set that dialect (apache/datafusion-sqlparser-rs#2622).
- A streaming fan-out for consumers that are known to be independent.

## Are these changes tested?

Yes. The new file
`datafusion/sqllogictest/test_files/cte_materialized.slt` covers:

- a volatile body, which returns one value when `MATERIALIZED` and
differs per reference when `NOT MATERIALIZED` or with no directive;
- `EXPLAIN`, which shows one `DataSourceExec` for three references;
- `EXPLAIN ANALYZE`, where the body buffers 4 rows once and each of the
three scans outputs 4 rows;
- a hash join whose two sides both read the CTE, with no deadlock;
- a scalar subquery reference;
- a CTE that reads an earlier CTE;
- sibling CTEs with the same name;
- `IN`, `NOT IN` and correlated `EXISTS` / `NOT EXISTS` / `IN`
subqueries that declare a materialized CTE;
- `WITH RECURSIVE` with recursive and non-recursive materialized CTEs;
- a view referenced two times (separate CTEs) and one reference with two
scans (one shared CTE);
- a filter from an outer query, which is pushed into the continuation;
- column aliases;
- a spill under `memory_limit`: 200k rows, about 184k spilled (part of
the in-memory prefix moves to disk to make room for the two readers),
and both scans still return 200k rows.
- a spill where the first batch is larger than the batches on disk: the
query finishes under an 18 MB limit, and failed before with an empty
pool.

The full sqllogictest suite passes.

## Are there any user-facing changes?

Yes. `WITH ... AS MATERIALIZED` is now honored when the Postgres dialect
is used. Queries without the directive are unchanged. There is no new
config option and no public API break. The new nodes are additions.

## Open questions for reviewers

1. Should these be a new `LogicalPlan` variant, like `RecursiveQuery`,
or stay as built-in `UserDefinedLogicalNode`s, as in this PR, which
keeps the change smaller?
2. Is it acceptable for protobuf serialization of these nodes to fail
until a follow-up PR adds it?

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
adriangb added a commit to pydantic/datafusion that referenced this pull request Oct 16, 2026
- Closes apache#17737.
- Related: apache#8777, apache#22676, apache#10337.

DataFusion inlines every CTE reference. When a query references the same
CTE several times, the CTE body is planned and executed once for each
reference. Postgres lets users opt out of this with `WITH x AS
MATERIALIZED (...)`. `sqlparser` already parses the directive into
`Cte.materialized`, but the SQL planner ignores it.

Earlier attempts (apache#22551, apache#22675) bundled automatic materialization,
heuristics and filter pushdown. This PR is limited to the explicit
directive, as proposed in
apache#17737 (comment):

- No config flag.
- No heuristic.
- Queries without `MATERIALIZED` plan exactly as before.

Deciding automatically when to materialize can be discussed separately.

Example:

```sql
WITH r AS MATERIALIZED (SELECT random() AS v)
SELECT a.v = b.v FROM r a, r b;
-- true: the body runs once. With NOT MATERIALIZED (or no directive) each reference evaluates random() separately.
```

- **Planner:** `datafusion/sql/src/cte.rs` and `query.rs` plan a
`MATERIALIZED` CTE as a `MaterializedCte` node, whose children are the
CTE body and the rest of the query, plus one `MaterializedCteScan` leaf
for each reference. These are `UserDefinedLogicalNode`s in
`datafusion-expr`.
  - `NOT MATERIALIZED` and the default keep today's inlining.
- `MATERIALIZED` also applies in a `WITH RECURSIVE` block. A recursive
CTE is still planned as `RecursiveQuery`, and its reference to itself
stays a work table scan.
- `head_output_expr`, `PullUpCorrelatedExpr` and `PushDownFilter` look
through `MaterializedCte` to its continuation, so `IN` / `EXISTS`
subqueries and filters from an outer query work as with an inlined CTE.
The body is never filtered.
- Each occurrence of a CTE in a plan gets its own id before physical
planning, so a view that is referenced two times runs its CTE two times,
as in PostgreSQL.
- **Physical plan:** `MaterializedCteExec` and `MaterializedCteScanExec`
in `datafusion-physical-plan`, and a physical planner pass that binds
each scan to its producer.
- **Execution:** the body is run to completion once, all body partitions
in parallel, on the first poll of any scan. It is started by one shared
future and runs in a `JoinSet` that is aborted when dropped. Every scan
then replays the buffered batches.
- **Why full buffering:** a bounded streaming fan-out deadlocks when,
for example, both sides of one hash join read the same CTE (see apache#8777).
This design cannot deadlock.
- **Memory:** buffered batches are tracked by a `MemoryReservation`
created with `can_spill(true)`. When the reservation cannot grow, the
rest of that partition is written to a spill file through
`SpillManager`. `SpillFile::read_stream` can be called by any number of
readers at the same time, so every scan replays both the in-memory
batches and the spill files. Scans read spill files without read-ahead,
and after buffering one decoded batch per concurrent reader is reserved.
If the pool cannot grant this, in-memory batches are moved to spill
files until it can. Each scan reserves memory for the largest batch on
disk, so an in-memory batch that is larger than the batches on disk is
written in pieces, and a move is made only when it releases more memory
than it adds to the scans.
apache#25910 adds a byte bound to
spill files with a better size model; when it merges, this operator can
use it and drop its own slicing. A body that reads an unbounded source
is rejected at planning time, because it would be buffered forever.

- **CTE names are not unique:** each scan binds to its producer through
an id allocated by the planner, not through the CTE name. Sibling
subqueries that declare CTEs with the same name are tested.
- **Alignment with `ScalarSubqueryExec`:** `MaterializedCteExec` follows
the same shape. The rest of the query is its output and passes through
input order, partitioning and statistics. A scan inside a scalar
subquery can start the body itself (tested).
- **Why not `WorkTable`:** `WorkTable` gives its batches to exactly one
consumer (`take()`) and returns an error when memory runs out;
`RecursiveQueryExec` does not spill. A materialized CTE needs N readers
and spilling. The scan does follow the same pattern as `WorkTableExec`:
a leaf that reads a handle bound at planning time.
- **Statistics:** the scans report unknown statistics for now. A leaf
cannot ask its producer for statistics through `StatisticsContext`. I
would like to do this in a follow-up. This is a known limitation that
can make a plan worse than the plan of the inlined CTE, not only leave
it the same: `JoinSelection` does not know that a scan is small, and can
put the other, larger input of a hash join on the build side (pinned in
`cte_materialized.slt`).

Tracked under the EPIC apache#22676:

- Protobuf serialization. These nodes have no proto encoding yet, so
serializing a plan that contains them fails in the default extension
codec (apache#26231).
- Statistics, ordering and partitioning on the scans (apache#26230).
- Release the buffer when the last scan finishes rather than when the
plan is dropped (apache#26233).
- Byte-bounded spill files (apache#26232).
- Keep the output per run, so that `reset_state` follows its contract
(apache#26229).
- Deciding automatically when to materialize (apache#26237).
- First-class `LogicalPlan` variants (apache#26238).
- Pushing filters that all references share into the CTE body (apache#26239).
- Pruning unused columns from the CTE body (apache#26240).
- `sqlparser` parses `[NOT] MATERIALIZED` only in `PostgreSqlDialect`,
so the tests set that dialect (apache/datafusion-sqlparser-rs#2622).
- A streaming fan-out for consumers that are known to be independent.

Yes. The new file
`datafusion/sqllogictest/test_files/cte_materialized.slt` covers:

- a volatile body, which returns one value when `MATERIALIZED` and
differs per reference when `NOT MATERIALIZED` or with no directive;
- `EXPLAIN`, which shows one `DataSourceExec` for three references;
- `EXPLAIN ANALYZE`, where the body buffers 4 rows once and each of the
three scans outputs 4 rows;
- a hash join whose two sides both read the CTE, with no deadlock;
- a scalar subquery reference;
- a CTE that reads an earlier CTE;
- sibling CTEs with the same name;
- `IN`, `NOT IN` and correlated `EXISTS` / `NOT EXISTS` / `IN`
subqueries that declare a materialized CTE;
- `WITH RECURSIVE` with recursive and non-recursive materialized CTEs;
- a view referenced two times (separate CTEs) and one reference with two
scans (one shared CTE);
- a filter from an outer query, which is pushed into the continuation;
- column aliases;
- a spill under `memory_limit`: 200k rows, about 184k spilled (part of
the in-memory prefix moves to disk to make room for the two readers),
and both scans still return 200k rows.
- a spill where the first batch is larger than the batches on disk: the
query finishes under an 18 MB limit, and failed before with an empty
pool.

The full sqllogictest suite passes.

Yes. `WITH ... AS MATERIALIZED` is now honored when the Postgres dialect
is used. Queries without the directive are unchanged. There is no new
config option and no public API break. The new nodes are additions.

1. Should these be a new `LogicalPlan` variant, like `RecursiveQuery`,
or stay as built-in `UserDefinedLogicalNode`s, as in this PR, which
keeps the change smaller?
2. Is it acceptable for protobuf serialization of these nodes to fail
until a follow-up PR adds it?

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit cdc2fc0)
adriangb added a commit to pydantic/datafusion that referenced this pull request Oct 16, 2026
## Which issue does this PR close?

- Closes apache#17737.
- Related: apache#8777, apache#22676, apache#10337.

## Rationale for this change

DataFusion inlines every CTE reference. When a query references the same
CTE several times, the CTE body is planned and executed once for each
reference. Postgres lets users opt out of this with `WITH x AS
MATERIALIZED (...)`. `sqlparser` already parses the directive into
`Cte.materialized`, but the SQL planner ignores it.

Earlier attempts (apache#22551, apache#22675) bundled automatic materialization,
heuristics and filter pushdown. This PR is limited to the explicit
directive, as proposed in
apache#17737 (comment):

- No config flag.
- No heuristic.
- Queries without `MATERIALIZED` plan exactly as before.

Deciding automatically when to materialize can be discussed separately.

Example:

```sql
WITH r AS MATERIALIZED (SELECT random() AS v)
SELECT a.v = b.v FROM r a, r b;
-- true: the body runs once. With NOT MATERIALIZED (or no directive) each reference evaluates random() separately.
```

## What changes are included in this PR?

- **Planner:** `datafusion/sql/src/cte.rs` and `query.rs` plan a
`MATERIALIZED` CTE as a `MaterializedCte` node, whose children are the
CTE body and the rest of the query, plus one `MaterializedCteScan` leaf
for each reference. These are `UserDefinedLogicalNode`s in
`datafusion-expr`.
  - `NOT MATERIALIZED` and the default keep today's inlining.
- `MATERIALIZED` also applies in a `WITH RECURSIVE` block. A recursive
CTE is still planned as `RecursiveQuery`, and its reference to itself
stays a work table scan.
- `head_output_expr`, `PullUpCorrelatedExpr` and `PushDownFilter` look
through `MaterializedCte` to its continuation, so `IN` / `EXISTS`
subqueries and filters from an outer query work as with an inlined CTE.
The body is never filtered.
- Each occurrence of a CTE in a plan gets its own id before physical
planning, so a view that is referenced two times runs its CTE two times,
as in PostgreSQL.
- **Physical plan:** `MaterializedCteExec` and `MaterializedCteScanExec`
in `datafusion-physical-plan`, and a physical planner pass that binds
each scan to its producer.
- **Execution:** the body is run to completion once, all body partitions
in parallel, on the first poll of any scan. It is started by one shared
future and runs in a `JoinSet` that is aborted when dropped. Every scan
then replays the buffered batches.
- **Why full buffering:** a bounded streaming fan-out deadlocks when,
for example, both sides of one hash join read the same CTE (see apache#8777).
This design cannot deadlock.
- **Memory:** buffered batches are tracked by a `MemoryReservation`
created with `can_spill(true)`. When the reservation cannot grow, the
rest of that partition is written to a spill file through
`SpillManager`. `SpillFile::read_stream` can be called by any number of
readers at the same time, so every scan replays both the in-memory
batches and the spill files. Scans read spill files without read-ahead,
and after buffering one decoded batch per concurrent reader is reserved.
If the pool cannot grant this, in-memory batches are moved to spill
files until it can. Each scan reserves memory for the largest batch on
disk, so an in-memory batch that is larger than the batches on disk is
written in pieces, and a move is made only when it releases more memory
than it adds to the scans.
apache#25910 adds a byte bound to
spill files with a better size model; when it merges, this operator can
use it and drop its own slicing. A body that reads an unbounded source
is rejected at planning time, because it would be buffered forever.

### Notes on earlier review feedback (apache#22675)

- **CTE names are not unique:** each scan binds to its producer through
an id allocated by the planner, not through the CTE name. Sibling
subqueries that declare CTEs with the same name are tested.
- **Alignment with `ScalarSubqueryExec`:** `MaterializedCteExec` follows
the same shape. The rest of the query is its output and passes through
input order, partitioning and statistics. A scan inside a scalar
subquery can start the body itself (tested).
- **Why not `WorkTable`:** `WorkTable` gives its batches to exactly one
consumer (`take()`) and returns an error when memory runs out;
`RecursiveQueryExec` does not spill. A materialized CTE needs N readers
and spilling. The scan does follow the same pattern as `WorkTableExec`:
a leaf that reads a handle bound at planning time.
- **Statistics:** the scans report unknown statistics for now. A leaf
cannot ask its producer for statistics through `StatisticsContext`. I
would like to do this in a follow-up. This is a known limitation that
can make a plan worse than the plan of the inlined CTE, not only leave
it the same: `JoinSelection` does not know that a scan is small, and can
put the other, larger input of a hash join on the build side (pinned in
`cte_materialized.slt`).

### Out of scope, possible follow-ups

Tracked under the EPIC apache#22676:

- Protobuf serialization. These nodes have no proto encoding yet, so
serializing a plan that contains them fails in the default extension
codec (apache#26231).
- Statistics, ordering and partitioning on the scans (apache#26230).
- Release the buffer when the last scan finishes rather than when the
plan is dropped (apache#26233).
- Byte-bounded spill files (apache#26232).
- Keep the output per run, so that `reset_state` follows its contract
(apache#26229).
- Deciding automatically when to materialize (apache#26237).
- First-class `LogicalPlan` variants (apache#26238).
- Pushing filters that all references share into the CTE body (apache#26239).
- Pruning unused columns from the CTE body (apache#26240).
- `sqlparser` parses `[NOT] MATERIALIZED` only in `PostgreSqlDialect`,
so the tests set that dialect (apache/datafusion-sqlparser-rs#2622).
- A streaming fan-out for consumers that are known to be independent.

## Are these changes tested?

Yes. The new file
`datafusion/sqllogictest/test_files/cte_materialized.slt` covers:

- a volatile body, which returns one value when `MATERIALIZED` and
differs per reference when `NOT MATERIALIZED` or with no directive;
- `EXPLAIN`, which shows one `DataSourceExec` for three references;
- `EXPLAIN ANALYZE`, where the body buffers 4 rows once and each of the
three scans outputs 4 rows;
- a hash join whose two sides both read the CTE, with no deadlock;
- a scalar subquery reference;
- a CTE that reads an earlier CTE;
- sibling CTEs with the same name;
- `IN`, `NOT IN` and correlated `EXISTS` / `NOT EXISTS` / `IN`
subqueries that declare a materialized CTE;
- `WITH RECURSIVE` with recursive and non-recursive materialized CTEs;
- a view referenced two times (separate CTEs) and one reference with two
scans (one shared CTE);
- a filter from an outer query, which is pushed into the continuation;
- column aliases;
- a spill under `memory_limit`: 200k rows, about 184k spilled (part of
the in-memory prefix moves to disk to make room for the two readers),
and both scans still return 200k rows.
- a spill where the first batch is larger than the batches on disk: the
query finishes under an 18 MB limit, and failed before with an empty
pool.

The full sqllogictest suite passes.

## Are there any user-facing changes?

Yes. `WITH ... AS MATERIALIZED` is now honored when the Postgres dialect
is used. Queries without the directive are unchanged. There is no new
config option and no public API break. The new nodes are additions.

## Open questions for reviewers

1. Should these be a new `LogicalPlan` variant, like `RecursiveQuery`,
or stay as built-in `UserDefinedLogicalNode`s, as in this PR, which
keeps the change smaller?
2. Is it acceptable for protobuf serialization of these nodes to fail
until a follow-up PR adds it?

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto detected api change Auto detected API change common Related to common crate core Core DataFusion crate documentation Improvements or additions to documentation logical-expr Logical plan and expressions physical-plan Changes to the physical-plan crate sql SQL Planner sqllogictest SQL Logic Tests (.slt) Stale PR has not had any activity for some time

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants