Skip to content

CommonSubexprEliminate + leaf-expression pushdown duplicate a UDF call wrapped in get_field #25329

Description

@bert-beyondloops

Describe the bug

When a non-pushable (KeepInPlace) function call appears once bare and once wrapped in get_field(...),

CommonSubexprEliminate correctly deduplicates the bare occurrence into a __common_expr_N column — but leaf-expression pushdown (ExtractLeafExpressions / PushDownLeafProjections) then re-derives the full original call a second time when it tries to push the get_field expression down, because __common_expr_N's Column reference is indistinguishable, at the ExpressionPlacement level, from a genuine cheap base-table column.

Net effect: the function is evaluated twice instead of once.

To Reproduce

Using here the arrow_field UDF as an example since this function returns a struct type.

  CREATE TABLE t (a INT) AS VALUES (1), (2);                                                                                                                                                                                       
                                                                                                                                                                                                                                   
  EXPLAIN SELECT                                                                                                                                                                                                                   
      CASE WHEN arrow_field(a) IS NOT NULL                                                                                                                                                                                         
           THEN get_field(arrow_field(a), 'name') END AS c1                                                                                                                                                                        
  FROM t;   

Actual physical plan

+---------------+-------------------------------+
| plan_type     | plan                          |
+---------------+-------------------------------+
| physical_plan | ┌───────────────────────────┐ |
|               | │       ProjectionExec      │ |
|               | │    --------------------   │ |
|               | │            c1:            │ |
|               | │  CASE WHEN arrow_field(a) │ |
|               | │      IS NOT NULL THEN     │ |
|               | │          get_field        │ |
|               | │      (arrow_field(a),     │ |
|               | │          name) END        │ |
|               | └─────────────┬─────────────┘ |
|               | ┌─────────────┴─────────────┐ |
|               | │       DataSourceExec      │ |
|               | │    --------------------   │ |
|               | │         bytes: 112        │ |
|               | │       format: memory      │ |
|               | │          rows: 1          │ |
|               | └───────────────────────────┘ |
|               |                               |
+---------------+-------------------------------+                                                                                                                              

The function (arrow_field) is called twice.

Expected behavior

Expected physical plan

+---------------+-------------------------------+
| plan_type     | plan                          |
+---------------+-------------------------------+
| physical_plan | ┌───────────────────────────┐ |
|               | │       ProjectionExec      │ |
|               | │    --------------------   │ |
|               | │            c1:            │ |
|               | │ CASE WHEN __common_expr_1 │ |
|               | │      IS NOT NULL THEN     │ |
|               | │          get_field        │ |
|               | │     (__common_expr_1,     │ |
|               | │          name) END        │ |
|               | └─────────────┬─────────────┘ |
|               | ┌─────────────┴─────────────┐ |
|               | │       ProjectionExec      │ |
|               | │    --------------------   │ |
|               | │      __common_expr_1:     │ |
|               | │       arrow_field(a)      │ |
|               | └─────────────┬─────────────┘ |
|               | ┌─────────────┴─────────────┐ |
|               | │       DataSourceExec      │ |
|               | │    --------------------   │ |
|               | │         bytes: 112        │ |
|               | │       format: memory      │ |
|               | │          rows: 1          │ |
|               | └───────────────────────────┘ |
|               |                               |
+---------------+-------------------------------+

The function (arrow_field) is called once;
get_field references the shared __common_expr_1 column.

Additional context

No response

Activity

  1. bert-beyondloops commented on Sep 15, 2026

    @bert-beyondloops
    ContributorAuthor

    take

  2. liningpan commented on Sep 15, 2026

    @liningpan
    Contributor

    Duplicate with #23655. There is a PR already #23691

  3. adriangb commented on Sep 16, 2026

    @adriangb
    Contributor

    Good timing!

    @bert-beyondloops does #23691 solve your issue? If there anything in #25330 that you think is still valuable (tests, slight tweak on implementation) I’d be happy to review it after a rebase.

  4. bert-beyondloops commented on Sep 16, 2026

    @bert-beyondloops
    ContributorAuthor

    Hi @adriangb

    Thanks for spotting the duplicate issue.
    I've marked #25330 as draft for the moment.

    I'll retest with your landed changes first.

  5. bert-beyondloops commented on Sep 16, 2026

    @bert-beyondloops
    ContributorAuthor

    Repro: a struct-returning function referenced both as a bare value (arrow_field(a) IS NOT NULL) and via get_field/[...] inside a Filter predicate is evaluated twice,
    even on the datafusion commit incorporating the fix: "evaluate struct-returning UDFs once across repeated field accesses", #23691.

    PR #23691 / issue #23655 only regression-tests this pattern inside a
    Projection; the same pattern inside a Filter predicate is not covered.

    Steps to reproduce :

    CREATE TABLE arrow_field_filter_cse_test(a INT) AS VALUES (1), (2);

    EXPLAIN FORMAT TREE SELECT a FROM arrow_field_filter_cse_test
    WHERE CASE WHEN arrow_field(a) IS NOT NULL THEN arrow_field(a)['nullable'] IS NULL END;

    +---------------+-------------------------------+
    | plan_type     | plan                          |
    +---------------+-------------------------------+
    | physical_plan | ┌───────────────────────────┐ |
    |               | │         FilterExec        │ |
    |               | │    --------------------   │ |
    |               | │         predicate:        │ |
    |               | │   __common_expr_7 IS NOT  │ |
    |               | │          NULL AND         │ |
    |               | │        __datafusion_      │ |
    |               | │    extracted_8 IS NULL    │ |
    |               | └─────────────┬─────────────┘ |
    |               | ┌─────────────┴─────────────┐ |
    |               | │       ProjectionExec      │ |
    |               | │    --------------------   │ |
    |               | │      __common_expr_7:     │ |
    |               | │       arrow_field(a)      │ |
    |               | │                           │ |
    |               | │ __datafusion_extracted_8: │ |
    |               | │ get_field(arrow_field(a), │ |
    |               | │          nullable)        │ |
    |               | │                           │ |
    |               | │            a: a           │ |
    |               | └─────────────┬─────────────┘ |
    |               | ┌─────────────┴─────────────┐ |
    |               | │       DataSourceExec      │ |
    |               | │    --------------------   │ |
    |               | │         bytes: 112        │ |
    |               | │       format: memory      │ |
    |               | │          rows: 2          │ |
    |               | └───────────────────────────┘ |
    |               |                               |
    +---------------+-------------------------------+
    

    The __common_expr_7 expression is only referenced once in the actual filter.

  6. pepijnve commented on Sep 16, 2026

    @pepijnve
    Contributor

    Here's an excerpt of the optimisation output from current main (606ae0f).

    initial_logical_plan                  | Projection: t.a
                                          |   Filter: CASE WHEN arrow_field(t.a) IS NOT NULL THEN get_field(arrow_field(t.a), Utf8("nullable")) IS NULL END
                                          |     TableScan: t
    after simplify_expressions            | Projection: t.a
                                          |   Filter: arrow_field(t.a) IS NOT NULL AND get_field(arrow_field(t.a), Utf8("nullable")) IS NULL OR arrow_field(t.a) IS NULL AND Boolean(NULL)
                                          |     TableScan: t
    after common_sub_expression_eliminate | Projection: t.a
                                          |   Projection: t.a
                                          |     Filter: __common_expr_1 IS NOT NULL AND get_field(__common_expr_1, Utf8("nullable")) IS NULL OR __common_expr_1 IS NULL AND Boolean(NULL)
                                          |       Projection: arrow_field(t.a) AS __common_expr_1, t.a
                                          |         TableScan: t
    after extract_leaf_expressions        | Projection: t.a
                                          |   Projection: t.a
                                          |     Projection: __common_expr_1, t.a
                                          |       Filter: __common_expr_1 IS NOT NULL AND __datafusion_extracted_2 IS NULL OR __common_expr_1 IS NULL AND Boolean(NULL)
                                          |         Projection: arrow_field(t.a) AS __common_expr_1, t.a, get_field(arrow_field(t.a), Utf8("nullable")) AS __datafusion_extracted_2
                                          |           TableScan: t
    

    Here too it's extract_leaf_expressions that's causing the function call duplication.

  7. adriangb commented on Sep 16, 2026

    @adriangb
    Contributor

    @bert-beyondloops would you like to open a PR to add the missing filter coverage / @pepijnve's example above? It'd be great to think about how we can close gaps generically so we're not patching individual cases.

  8. pepijnve commented on Sep 16, 2026

    @pepijnve
    Contributor

    With a minor change to extract_leaf_expressions I was able to get to

    after extract_leaf_expressions   | Projection: t.a
                                     |   Projection: t.a
                                     |     Projection: __common_expr_1, t.a
                                     |       Filter: __common_expr_1 IS NOT NULL AND __datafusion_extracted_2 IS NULL OR __common_expr_1 IS NULL AND Boolean(NULL)
                                     |         Projection: get_field(__common_expr_1, Utf8("nullable")) AS __datafusion_extracted_2, __common_expr_1, t.a
                                     |           Projection: arrow_field(t.a) AS __common_expr_1, t.a
                                     |             TableScan: t
    after push_down_leaf_projections | SAME TEXT AS ABOVE
    after optimize_projections       | Projection: t.a
                                     |   Filter: __common_expr_1 IS NOT NULL AND __datafusion_extracted_2 IS NULL OR __common_expr_1 IS NULL AND Boolean(NULL)
                                     |     Projection: get_field(__common_expr_1, Utf8("nullable")) AS __datafusion_extracted_2, __common_expr_1, t.a
                                     |       Projection: arrow_field(t.a) AS __common_expr_1, t.a
                                     |         TableScan: t projection=[a]
    

    which is what we're going for I think.

    a little bit further down the line push_down_filter goes ahead and ruins things again.

    after push_down_filter | Projection: t.a
                           |   Filter: __datafusion_extracted_2 IS NULL
                           |     Projection: get_field(__common_expr_1, Utf8("nullable")) AS __datafusion_extracted_2, __common_expr_1, t.a
                           |       Projection: arrow_field(t.a) AS __common_expr_1, t.a
                           |         Filter: arrow_field(t.a) IS NOT NULL
                           |           TableScan: t projection=[a]
    

    Does that seem like something we should prevent as well? Moving a NOT NULL filter before a projection that computes the same value doesn't seem sensible.

  9. pepijnve commented on Sep 16, 2026

    @pepijnve
    Contributor

    I think I've been able to come up with some minimal tweaks that resolve the remaining issues described above. I'll try to get a pr ready later today.

  10. bert-beyondloops commented on Oct 5, 2026

    @bert-beyondloops
    ContributorAuthor

    Issue: ExpressionPlacement::KeepInPlace isn't enforced, and its current default makes it unusable for that purpose

    KeepInPlace's doc says it marks "an expensive expression that should stay where it is... complex scalar functions or UDFs." But push_down_filter.rs::rewrite_projection's guard only checks is_volatile() and placement() == MoveTowardsLeafNodes — never KeepInPlace — so a filter referencing a projected column can still be pushed/substituted through that projection, duplicating an expensive expression multiple times.

    Naively adding a KeepInPlace check doesn't work either: Expr::placement()'s catch-all default is KeepInPlace for anything that isn't a bare Column/Literal/Alias, and ScalarUDFImpl::placement()'s default is also KeepInPlace
    for any function that doesn't override it. So ordinary a + 1, CASE, CAST, and every unclassified function call already resolve to KeepInPlace today. Gating pushdown on it would block common, cheap, currently-working optimizations (e.g. pushing a filter on a + 1 AS b down to the scan), not just genuinely expensive expressions.

    Core problem: the default collides "deliberately expensive, don't duplicate" with "nobody classified this" — both produce the same enum value, so no optimizer can safely treat KeepInPlace as a duplication guard.

    Questions:

    1. Was KeepInPlace meant to be actively enforced somewhere, or is the doc comment aspirational with no current consumer?
    2. Should the blanket defaults (Expr::placement()'s catch-all and ScalarUDFImpl::placement()'s default) return a distinct variant instead, reserving KeepInPlace for explicit opt-in — so pushdown rules can gate on it safely?

    Is this design issue being tackled already in many of those outstanding issues mentioned in #25459 ?

    Honestly, a colleague of mine (@pepijnve) and I have lost track of the overall direction across these issues — where the design is heading and how the various fixes are meant to fit together.

    @adriangb We definitely want to help out some way or another.

  11. adriangb commented on Oct 28, 2026

    @adriangb
    Contributor

    Honestly, a colleague of mine (@pepijnve) and I have lost track of the overall direction across these issues — where the design is heading and how the various fixes are meant to fit together.

    I'm sorry I've let this get to this state. I need to invest time that I simply don't have in refreshing my mind on these implementations and tying together all of the loose ends. I'll get to it in the next couple of weeks.

    Maybe it would help if I wrote a high level description of what the problem we are trying to solve is in my mind? Or maybe we can add all of the different cases we think are handled wrong as tests and work from there TDD style?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't working

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions