Thread: pgsql: Avoid mislabeling of lateral references when pulling up a subque

Avoid mislabeling of lateral references when pulling up a subquery.

If we are pulling up a subquery that's under an outer join, and
the subquery's target list contains a strict expression that uses
both a subquery variable and a lateral-reference variable, it's okay
to pull up the expression without wrapping it in a PlaceHolderVar.
That's safe because if the subquery variable is forced to NULL
by the outer join, the expression result will come out as NULL too,
so we don't have to force that outcome by evaluating the expression
below the outer join.  It'd be correct to wrap in a PHV, but that can
lead to very significantly worse plans, since we'd then have to use
a nestloop plan to pass down the lateral reference to where the
expression will be evaluated.

However, when we do that, we should not mark the lateral reference
variable as being nulled by the outer join, because it isn't after
we pull up the expression in this way.  So the marking logic added
by cb8e50a4a was incorrect in this detail, leading to "wrong
varnullingrels" errors from the consistency-checking logic in
setrefs.c.  It seems to be sufficient to just not mark lateral
references at all in this case.  (I have a nagging feeling that more
complexity may be needed in cases where there are several levels of
outer join, but some attempts to break it with that didn't succeed.)

Per report from Bertrand Mamasam.  Back-patch to v16, as the previous
patch was.

Discussion: https://postgr.es/m/CACZ67_UA_EVrqiFXJu9XK50baEpH=ofEPJswa2kFxg6xuSw-ww@mail.gmail.com

Branch
------
REL_16_STABLE

Details
-------
https://git.postgresql.org/pg/commitdiff/85990e2fd5610576635c65db9292297b1730c947

Modified Files
--------------
src/backend/optimizer/prep/prepjointree.c | 19 +++++++++++--
src/test/regress/expected/subselect.out   | 47 +++++++++++++++++++++++++++++++
src/test/regress/sql/subselect.sql        | 16 +++++++++++
3 files changed, 80 insertions(+), 2 deletions(-)


On Fri, Nov 29, 2024 at 7:33 AM Tom Lane <tgl@sss.pgh.pa.us> wrote:
> It seems to be sufficient to just not mark lateral
> references at all in this case.  (I have a nagging feeling that more
> complexity may be needed in cases where there are several levels of
> outer join, but some attempts to break it with that didn't succeed.)

You're right about your feeling.  Here is a query that breaks it.

create table t (a int, b int);

explain (costs off)
select x from
  t t1 left join
  (t t2 left join
   lateral (select t2.a+t3.a as x, * from t t3) t3 on t2.a <> t3.a)
  on t1.b = t2.b;
ERROR:  wrong varnullingrels (b) (expected (b 5)) for Var 2/1

'x' is nulled by ojrelids {4, 5}.  When pulling up the subquery, it's
right that we should not mark the lateral reference variable 't2' as
being nulled by {4}, but we should mark it as being nulled by {5}.

Thanks
Richard



Richard Guo <guofenglinux@gmail.com> writes:
> On Fri, Nov 29, 2024 at 7:33 AM Tom Lane <tgl@sss.pgh.pa.us> wrote:
>> It seems to be sufficient to just not mark lateral
>> references at all in this case.  (I have a nagging feeling that more
>> complexity may be needed in cases where there are several levels of
>> outer join, but some attempts to break it with that didn't succeed.)

> You're right about your feeling.  Here is a query that breaks it.

Ah, thanks for the test case.  I'll look into it tomorrow.

            regards, tom lane



Richard Guo <guofenglinux@gmail.com> writes:
> I spent some time looking into this issue.

Thanks for looking at it!

> First of all, the lateral references cannot be outside of the lowest
> outer join above the subquery; otherwise, is_simple_subquery() would
> consider the subquery not eligible for pull-up.

Yeah.  While looking at this, I've been wondering if we don't have
enough infrastructure now to relax is_simple_subquery's restrictions.
The way it is now was designed to keep the world safe for placing
quals based on is_pushed_down flags (cf c64de21e9), but maybe with
OJ-labeled Vars we don't need to be so restrictive.  Clearly, a
back-patched bug fix is no place to actually make such a change,
but we might want to code the fix with the thought in mind that that
could happen later.  In particular, I'm not sure I buy the theory
that there's just one relid to get rid of when calculating the
appropriate nullingrels for a lateral reference.

Also, I don't think I buy the assumption that all the lateral
references need the same nullingrels.  Your test case could easily
be extended to have lateral refs coming from two different levels
of outer join.  So I think we need to calculate the right new
nullingrels for each lateral-ref varno appearing in the expression,
and do add_nulling_relids() over again for each one.

The ideas I'd been toying with last night involved a pre-scan over
the join tree to calculate the potential nullingrels of each leaf RTE
(same idea as RelOptInfo.nulling_relids, but of course we don't have
any RelOptInfos yet).  That seems painful though because we'd have to
update the data structure somehow after each subquery pullup.  It
might be better just to assume that pulled-up lateral references are
uncommon and push the work into the case where we have one, rather than
try to do setup work to make that case cheaper.  With that approach,
you could imagine writing a function that traverses the join tree
from the top and computes the set of OJ relids that can potentially
null a particular target relid.  Then we'd intersect that with the
varnullingrels of the Var being replaced to derive the correct
nullingrels for the lateral-ref Vars.  (If we do it like that,
we may not need lowest_nullable_side etc yet, but I attach some
comments on that code anyway.)

> I've drafted a patch based on this idea.  I borrowed the concept of
> lowest_nullable_relids from another patch of mine [1], which uses
> lowest_nullable_relids to avoid wrapping lateral references that are
> under the same lowest nulling outer join.

I find lowest_nullable_side fairly messy/confusing, in particular
that it might point at something that's not either side of the
lowest_outer_join parameter.  I wonder whether there's another way
to do that.  At the very least, the header comment for
pull_up_subqueries_recurse should point out that the two values
might be unrelated.

The calculation of lowest_nullable_relids in pull_up_simple_subquery
doesn't feel right --- it needs some comments at least.

On the same reasoning that a back-patched fix is no place to be
introducing unnecessary changes, I don't agree with revising the
rules for whether to wrap plain Vars/PHVs in this patch.  We can
do that in a separate HEAD-only patch.

Do you want to continue working on this, or shall I take it?
The bug is my fault in the end :-(

            regards, tom lane



I wrote:
> The ideas I'd been toying with last night involved a pre-scan over
> the join tree to calculate the potential nullingrels of each leaf RTE
> (same idea as RelOptInfo.nulling_relids, but of course we don't have
> any RelOptInfos yet).  That seems painful though because we'd have to
> update the data structure somehow after each subquery pullup.

I realized that we can make that work by doing the pre-calculation
at the start of each pull_up_simple_subquery.  We only have to do
it for subqueries marked LATERAL, so this doesn't seem too horribly
inefficient.  Draft patch attached.  I didn't spend effort on
devising test cases, but it works for your example.

            regards, tom lane

diff --git a/src/backend/optimizer/prep/prepjointree.c b/src/backend/optimizer/prep/prepjointree.c
index 2e0d41a8d1..ff9c1cd24e 100644
--- a/src/backend/optimizer/prep/prepjointree.c
+++ b/src/backend/optimizer/prep/prepjointree.c
@@ -42,6 +42,17 @@
 #include "rewrite/rewriteManip.h"


+typedef struct nullingrel_info
+{
+    /*
+     * For each leaf RTE, nullingrels[rti] is the set of relids of outer joins
+     * that potentially null that RTE.
+     */
+    Relids       *nullingrels;
+    /* Length of range table (maximum index in nullingrels[]) */
+    int            rtlength;        /* used only for assertion checks */
+} nullingrel_info;
+
 typedef struct pullup_replace_vars_context
 {
     PlannerInfo *root;
@@ -49,6 +60,8 @@ typedef struct pullup_replace_vars_context
     RangeTblEntry *target_rte;    /* RTE of subquery */
     Relids        relids;            /* relids within subquery, as numbered after
                                  * pullup (set only if target_rte->lateral) */
+    nullingrel_info *nullinfo;    /* per-RTE nullingrel info (set only if
+                                 * target_rte->lateral) */
     bool       *outer_hasSubLinks;    /* -> outer query's hasSubLinks */
     int            varno;            /* varno of subquery */
     bool        wrap_non_vars;    /* do we need all non-Var outputs to be PHVs? */
@@ -142,6 +155,9 @@ static void substitute_phv_relids(Node *node,
 static void fix_append_rel_relids(PlannerInfo *root, int varno,
                                   Relids subrelids);
 static Node *find_jointree_node_for_rel(Node *jtnode, int relid);
+static nullingrel_info *get_nullingrels(Query *parse);
+static void get_nullingrels_recurse(Node *jtnode, Relids upper_nullingrels,
+                                    nullingrel_info *info);


 /*
@@ -1259,10 +1275,16 @@ pull_up_simple_subquery(PlannerInfo *root, Node *jtnode, RangeTblEntry *rte,
     rvcontext.targetlist = subquery->targetList;
     rvcontext.target_rte = rte;
     if (rte->lateral)
+    {
         rvcontext.relids = get_relids_in_jointree((Node *) subquery->jointree,
                                                   true, true);
-    else                        /* won't need relids */
+        rvcontext.nullinfo = get_nullingrels(parse);
+    }
+    else                        /* won't need these fields */
+    {
         rvcontext.relids = NULL;
+        rvcontext.nullinfo = NULL;
+    }
     rvcontext.outer_hasSubLinks = &parse->hasSubLinks;
     rvcontext.varno = varno;
     /* this flag will be set below, if needed */
@@ -1809,7 +1831,8 @@ pull_up_simple_values(PlannerInfo *root, Node *jtnode, RangeTblEntry *rte)
     rvcontext.root = root;
     rvcontext.targetlist = tlist;
     rvcontext.target_rte = rte;
-    rvcontext.relids = NULL;
+    rvcontext.relids = NULL;    /* can't be any lateral references here */
+    rvcontext.nullinfo = NULL;
     rvcontext.outer_hasSubLinks = &parse->hasSubLinks;
     rvcontext.varno = varno;
     rvcontext.wrap_non_vars = false;
@@ -1971,9 +1994,10 @@ pull_up_constant_function(PlannerInfo *root, Node *jtnode,
     /*
      * Since this function was reduced to a Const, it doesn't contain any
      * lateral references, even if it's marked as LATERAL.  This means we
-     * don't need to fill relids.
+     * don't need to fill relids or nullinfo.
      */
     rvcontext.relids = NULL;
+    rvcontext.nullinfo = NULL;

     rvcontext.outer_hasSubLinks = &parse->hasSubLinks;
     rvcontext.varno = ((RangeTblRef *) jtnode)->rtindex;
@@ -2688,9 +2712,42 @@ pullup_replace_vars_callback(Var *var,
         {
             /*
              * There should be Vars/PHVs within the expression that we can
-             * modify.  Per above discussion, modify only Vars/PHVs of the
-             * subquery, not lateral references.
+             * modify.  Vars/PHVs of the subquery should have the full
+             * var->varnullingrels added to them, but if there are lateral
+             * references within the expression, those must be marked with
+             * only the nullingrels that potentially apply to them.  That
+             * could be different for different lateral relations, so we have
+             * to do this work for each one.
              */
+            if (rcon->target_rte->lateral)
+            {
+                nullingrel_info *nullinfo = rcon->nullinfo;
+                Relids        lvarnos;
+                int            lvarno;
+
+                /*
+                 * Identify lateral varnos used within newnode.  We must do
+                 * this before injecting var->varnullingrels into the tree.
+                 */
+                lvarnos = pull_varnos(rcon->root, newnode);
+                lvarnos = bms_del_members(lvarnos, rcon->relids);
+                /* For each one, add relevant nullingrels if any */
+                lvarno = -1;
+                while ((lvarno = bms_next_member(lvarnos, lvarno)) >= 0)
+                {
+                    Relids        lnullingrels;
+
+                    Assert(lvarno > 0 && lvarno <= nullinfo->rtlength);
+                    lnullingrels = bms_intersect(var->varnullingrels,
+                                                 nullinfo->nullingrels[lvarno]);
+                    if (!bms_is_empty(lnullingrels))
+                        newnode = add_nulling_relids(newnode,
+                                                     bms_make_singleton(lvarno),
+                                                     lnullingrels);
+                }
+            }
+
+            /* Finally, deal with Vars/PHVs of the subquery itself */
             newnode = add_nulling_relids(newnode,
                                          rcon->relids,
                                          var->varnullingrels);
@@ -4120,3 +4177,94 @@ find_jointree_node_for_rel(Node *jtnode, int relid)
              (int) nodeTag(jtnode));
     return NULL;
 }
+
+/*
+ * get_nullingrels: collect info about which outer joins null which relations
+ *
+ * The result struct contains, for each leaf relation used in the query,
+ * the set of relids of outer joins that potentially null that rel.
+ */
+static nullingrel_info *
+get_nullingrels(Query *parse)
+{
+    nullingrel_info *result = palloc_object(nullingrel_info);
+
+    result->rtlength = list_length(parse->rtable);
+    result->nullingrels = palloc0_array(Relids, result->rtlength + 1);
+    get_nullingrels_recurse((Node *) parse->jointree, NULL, result);
+    return result;
+}
+
+/*
+ * Recursive guts of get_nullingrels().
+ *
+ * Note: at any recursion level, the passed-down upper_nullingrels must be
+ * treated as a constant, but it can be stored directly into *info
+ * if we're at leaf level.  Upper recursion levels do not free their mutated
+ * copies of the nullingrels, because those are probably referenced by
+ * at least one leaf rel.
+ */
+static void
+get_nullingrels_recurse(Node *jtnode, Relids upper_nullingrels,
+                        nullingrel_info *info)
+{
+    if (jtnode == NULL)
+        return;
+    if (IsA(jtnode, RangeTblRef))
+    {
+        int            varno = ((RangeTblRef *) jtnode)->rtindex;
+
+        Assert(varno > 0 && varno <= info->rtlength);
+        info->nullingrels[varno] = upper_nullingrels;
+    }
+    else if (IsA(jtnode, FromExpr))
+    {
+        FromExpr   *f = (FromExpr *) jtnode;
+        ListCell   *l;
+
+        foreach(l, f->fromlist)
+        {
+            get_nullingrels_recurse(lfirst(l), upper_nullingrels, info);
+        }
+    }
+    else if (IsA(jtnode, JoinExpr))
+    {
+        JoinExpr   *j = (JoinExpr *) jtnode;
+        Relids        local_nullingrels;
+
+        switch (j->jointype)
+        {
+            case JOIN_INNER:
+                get_nullingrels_recurse(j->larg, upper_nullingrels, info);
+                get_nullingrels_recurse(j->rarg, upper_nullingrels, info);
+                break;
+            case JOIN_LEFT:
+            case JOIN_SEMI:
+            case JOIN_ANTI:
+                local_nullingrels = bms_add_member(bms_copy(upper_nullingrels),
+                                                   j->rtindex);
+                get_nullingrels_recurse(j->larg, upper_nullingrels, info);
+                get_nullingrels_recurse(j->rarg, local_nullingrels, info);
+                break;
+            case JOIN_FULL:
+                local_nullingrels = bms_add_member(bms_copy(upper_nullingrels),
+                                                   j->rtindex);
+                get_nullingrels_recurse(j->larg, local_nullingrels, info);
+                get_nullingrels_recurse(j->rarg, local_nullingrels, info);
+                break;
+            case JOIN_RIGHT:
+                local_nullingrels = bms_add_member(bms_copy(upper_nullingrels),
+                                                   j->rtindex);
+                get_nullingrels_recurse(j->larg, local_nullingrels, info);
+                get_nullingrels_recurse(j->rarg, upper_nullingrels, info);
+                break;
+            default:
+                elog(ERROR, "unrecognized join type: %d",
+                     (int) j->jointype);
+                break;
+        }
+    }
+    else
+        elog(ERROR, "unrecognized node type: %d",
+             (int) nodeTag(jtnode));
+}
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index b54428b38c..2d4c870423 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3665,6 +3665,7 @@ nodeitem
 normal_rand_fctx
 nsphash_hash
 ntile_context
+nullingrel_info
 numeric
 object_access_hook_type
 object_access_hook_type_str

On Sat, Nov 30, 2024 at 6:31 AM Tom Lane <tgl@sss.pgh.pa.us> wrote:
> I wrote:
> > The ideas I'd been toying with last night involved a pre-scan over
> > the join tree to calculate the potential nullingrels of each leaf RTE
> > (same idea as RelOptInfo.nulling_relids, but of course we don't have
> > any RelOptInfos yet).  That seems painful though because we'd have to
> > update the data structure somehow after each subquery pullup.
>
> I realized that we can make that work by doing the pre-calculation
> at the start of each pull_up_simple_subquery.  We only have to do
> it for subqueries marked LATERAL, so this doesn't seem too horribly
> inefficient.  Draft patch attached.  I didn't spend effort on
> devising test cases, but it works for your example.

This patch looks good to me.  I like that it will still work if,
someday, the restrictions of is_simple_subquery are relaxed to allow
lateral references to be outside the lowest outer join above the
subquery.

This patch can also simplify my other patch, which is to avoid
unnecessary wrapping for plain Vars/PHVs.  We can check the new
nullingrel_info to see if the nullingrels of the subquery RTE are a
subset of the nullingrels of the lateral referenced rel, to determine
if the referenced rel is under the same lowest nulling outer join.
And this eliminates the need to introduce lowest_nullable_side.

Thanks
Richard



Richard Guo <guofenglinux@gmail.com> writes:
> This patch can also simplify my other patch, which is to avoid
> unnecessary wrapping for plain Vars/PHVs.  We can check the new
> nullingrel_info to see if the nullingrels of the subquery RTE are a
> subset of the nullingrels of the lateral referenced rel, to determine
> if the referenced rel is under the same lowest nulling outer join.
> And this eliminates the need to introduce lowest_nullable_side.

Right, because that's more or less the same problem.  Or indeed
it's exactly the same problem, we're just making fast paths for
the easiest cases.

Another thought: the reason I made get_nullingrels return a new
struct rather than tying it directly to filling some fields in
pullup_replace_vars_context was that I think we might want to
reconsider where to call it from.  Perhaps it'd be useful to
have the info during is_simple_subquery, for example.

            regards, tom lane



Re: pgsql: Avoid mislabeling of lateral references when pulling up a subque

From
Andrei Lepikhov
Date:
On 11/29/24 05:33, Tom Lane wrote:
> Avoid mislabeling of lateral references when pulling up a subquery.
> 
> If we are pulling up a subquery that's under an outer join, and
> the subquery's target list contains a strict expression that uses
> both a subquery variable and a lateral-reference variable, it's okay
> to pull up the expression without wrapping it in a PlaceHolderVar.
> That's safe because if the subquery variable is forced to NULL
> by the outer join, the expression result will come out as NULL too,
> so we don't have to force that outcome by evaluating the expression
> below the outer join.  It'd be correct to wrap in a PHV, but that can
> lead to very significantly worse plans, since we'd then have to use
> a nestloop plan to pass down the lateral reference to where the
> expression will be evaluated.
Pardon the noise, but I'm curious why the optimiser must choose NestLoop 
in the case of lateral reference.

It would be nice to provide alternatives. Because now we have some 
corner cases. For example, with pull-up correlated subqueries, we've got 
one degraded case. Look the following:

DROP TABLE IF EXISTS t1,t2;
CREATE TABLE t1(x int, y int);
CREATE TABLE t2(x int, y int);
INSERT INTO t1 (x,y)
   SELECT gs,-gs FROM generate_series(1,1E4) AS gs;
ANALYZE t1,t2;

EXPLAIN (ANALYZE, COSTS ON)
SELECT t1.* FROM t1 LEFT JOIN LATERAL (
   SELECT t3.* FROM t1 t3 WHERE t3.x=t1.x) AS t4
ON (t4.y IN (SELECT y FROM t2 WHERE t4.x=t2.x));

In previous versions Postgres executed this plan in milliseconds:

  Hash Left Join
    Hash Cond: (t1.x = t3.x)
    ->  Seq Scan on t1
    ->  Hash
          ->  Seq Scan on t1 t3
                Filter: (ANY (y = (SubPlan 1).col1))
                SubPlan 1
                  ->  Seq Scan on t2
                        Filter: (t3.x = x)
  Planning Time: 0.175 ms
  Execution Time: 6.396 ms

But now we have seconds:

  Nested Loop Left Join
    ->  Seq Scan on t1
    ->  Nested Loop Semi Join
          Join Filter: ((t3.x = t2.x) AND (t3.y = t2.y))
          ->  Seq Scan on t1 t3
                Filter: (x = t1.x)
          ->  Seq Scan on t2
  Planning Time: 1.309 ms
  Execution Time: 6780.217 ms

Correlated subquery pull-up is a nice optimisation, of course. So, why 
not let optimiser try a HashJoin like that (not a really generated 
plan, just my imagination):

  Hash Left Join
    Hash Cond: (t1.x = t3.x)
    ->  Seq Scan on t1
    Hash
    ->  Nested Loop Semi Join
          Join Filter: ((t3.x = t2.x) AND (t3.y = t2.y))
          ->  Seq Scan on t1 t3
          ->  Seq Scan on t2

Does the optimiser have some internal limits to let such a path?

-- 
regards, Andrei Lepikhov



Andrei Lepikhov <lepihov@gmail.com> writes:
> Pardon the noise, but I'm curious why the optimiser must choose NestLoop 
> in the case of lateral reference.

To pass down the current outer row's value of the lateral reference.

> It would be nice to provide alternatives. Because now we have some 
> corner cases.

That's a fairly bizarre case, and I'm not at all convinced that
the old plan was correct.

            regards, tom lane