Misresolved $lookup 'let' path when 'as' prefix overlaps local field

    • Type: Bug
    • Resolution: Unresolved
    • Priority: Major - P3
    • None
    • Affects Version/s: None
    • Component/s: None
    • Query Optimization
    • ALL
    • Hide
      /**
       * Repro: a $lookup whose `as` field has the same prefix as a local path referenced through
       * `let` in the sub-pipeline's $expr.
       *
       * @tags: [
       *   requires_fcv_90,
       *   requires_sbe,
       * ]
       */
      
      
      TestData.cleanUpCoreDumpsFromExpectedCrash = true;
      const conn = MongoRunner.runMongod({
          useLogFiles: false,
          setParameter: {
              featureFlagPathArrayness: true,
              internalEnableJoinOptimization: true,
              internalEnablePathArrayness: true,
              internalJoinReorderMode: "bottomUp",
          },
      });
      assert(conn);
      const testDB = conn.getDB(jsTestName());
      const base = testDB.base;
      const foreign = testDB.foreign;
      
      // 'X.y' lives in the base collection. The join uses 'X.y' as the local side of the predicate and
      // 'z' as the foreign side, while the $lookup overwrites 'X' via `as: "X"`.
      assert.commandWorked(base.insert({_id: 0, X: {y: 1}}));
      assert.commandWorked(foreign.insert({_id: "f", z: 1}));
      
      // Indexes are required so the path-arrayness API can prove that `base.X.y` and `foreign.z` are
      // scalar — otherwise the optimizer bails out before the bug is reached.
      assert.commandWorked(base.createIndex({"X.y": 1}));
      assert.commandWorked(foreign.createIndex({z: 1}));
      const pipeline = [
          {
              $lookup: {
                  from: foreign.getName(),
                  as: "X",
                  let: {l: "$X.y"},
                  pipeline: [{$match: {$expr: {$eq: ["$z", "$$l"]}}}],
              },
          },
          {$unwind: "$X"},
      ];
      
      // Sanity check the naive plan.
      assert.commandWorked(testDB.adminCommand({setParameter: 1, internalEnableJoinOptimization: false}));
      const naive = base.aggregate(pipeline).toArray();
      assert.eq(
          [{_id: 0, X: {_id: "f", z: 1}}],
          naive,
          "Naive $lookup must succeed and match base.X.y to foreign.z",
      );
      
      assert.commandWorked(testDB.adminCommand({setParameter: 1, internalEnableJoinOptimization: true}));
      const cmdRes = testDB.runCommand({aggregate: base.getName(), pipeline, cursor: {}});
      
      // WILL FAIL: 11180001 "Self edges are not permitted"
      assert.commandWorked(cmdRes);MongoRunner.stopMongod(conn, null, {allowedExitCode: MongoRunner.EXIT_ABORT});
       
      Show
      /**  * Repro: a $lookup whose `as` field has the same prefix as a local path referenced through  * `let` in the sub-pipeline's $expr.  *  * @tags: [  *   requires_fcv_90,  *   requires_sbe,  * ]  */ TestData.cleanUpCoreDumpsFromExpectedCrash = true ; const conn = MongoRunner.runMongod({     useLogFiles: false ,     setParameter: {         featureFlagPathArrayness: true ,         internalEnableJoinOptimization: true ,         internalEnablePathArrayness: true ,         internalJoinReorderMode: "bottomUp" ,     }, }); assert (conn); const testDB = conn.getDB(jsTestName()); const base = testDB.base; const foreign = testDB.foreign; // 'X.y' lives in the base collection. The join uses 'X.y' as the local side of the predicate and // 'z' as the foreign side, while the $lookup overwrites 'X' via `as: "X" `. assert .commandWorked(base.insert({_id: 0, X: {y: 1}})); assert .commandWorked(foreign.insert({_id: "f" , z: 1})); // Indexes are required so the path-arrayness API can prove that `base.X.y` and `foreign.z` are // scalar — otherwise the optimizer bails out before the bug is reached. assert .commandWorked(base.createIndex({ "X.y" : 1})); assert .commandWorked(foreign.createIndex({z: 1})); const pipeline = [     {         $lookup: {             from: foreign.getName(),             as: "X" ,             let: {l: "$X.y" },             pipeline: [{$match: {$expr: {$eq: [ "$z" , "$$l" ]}}}],         },     },     {$unwind: "$X" }, ]; // Sanity check the naive plan. assert .commandWorked(testDB.adminCommand({setParameter: 1, internalEnableJoinOptimization: false })); const naive = base.aggregate(pipeline).toArray(); assert .eq(     [{_id: 0, X: {_id: "f" , z: 1}}],     naive,     "Naive $lookup must succeed and match base.X.y to foreign.z" , ); assert .commandWorked(testDB.adminCommand({setParameter: 1, internalEnableJoinOptimization: true })); const cmdRes = testDB.runCommand({aggregate: base.getName(), pipeline, cursor: {}}); // WILL FAIL: 11180001 "Self edges are not permitted" assert .commandWorked(cmdRes);MongoRunner.stopMongod(conn, null , {allowedExitCode: MongoRunner.EXIT_ABORT});
    • None
    • None
    • None
    • None
    • None
    • None
    • None

      A $lookup can fail under join optimization when its `as` field has the same prefix as a local path referenced through `let` in the sub-pipeline's $expr.

      The optimizer adds the foreign node to the PathResolver before resolving the `let` RHS. If the lookup uses `as: "X"` and the local side references `"$X.y"`, that path is resolved as a foreign path instead of the local document path. The resulting equality predicate connects the foreign node to itself and triggers the "Self edges are not permitted" tassert while adding the join edge.

      Without join optimization, $lookup correctly evaluates `"$X.y"` on the local document and returns the expected result.

            Assignee:
            Unassigned
            Reporter:
            Max Verbinnen
            Votes:
            0 Vote for this issue
            Watchers:
            1 Start watching this issue

              Created:
              Updated: