ExportXMLWordPrintableJSON

    • Type: Bug
    • Resolution: Unresolved
    • Priority: Major - P3
    • None
    • Affects Version/s: None
    • Component/s: None
    • Query Execution
    • ALL
    • None
    • None
    • None
    • None
    • None
    • None
    • None

      Filed by the AI query bug hunter (qhunt), an agent that reviews query code for corner-case bugs. A differential oracle and the repro below confirmed the bug.

      Problem

      With join optimization on, a $match on a foreign field after $lookup + $unwind drops foreign documents where that field is an array. It happens when the base collection has a non-multikey index on a field with the same name. The query returns no error, only fewer rows.

      Repro

      Fails at master d4834185d93 (2026-10-02). Self-contained jstests/noPassthrough-style test:

      /**
       * F001: with join optimization on, a filter on a foreign collection field reads
       * path arrayness for the base collection's namespace. The base collection's
       * non-multikey index on "x" marks "x" non-array, so the foreign scan skips
       * array traversal and drops foreign docs whose "x" is an array.
       *
       * @tags: [
       *   requires_fcv_90,
       *   requires_sbe
       * ]
       */
      
      const conn = MongoRunner.runMongod({
          setParameter: {
              featureFlagPathArrayness: true,
              // Toggled knobs do not clear cached plans; keep every run a fresh plan.
              internalQueryDisablePlanCache: true,
          },
      });
      const db = conn.getDB(jsTestName());
      
      const base = db.base;
      const foreign = db.foreign;
      assert.commandWorked(base.insertMany([
          {_id: 1, k: 1, x: 5},
          {_id: 2, k: 2, x: 6},
      ]));
      assert.commandWorked(foreign.insertMany([
          {_id: 10, k: 1, x: [1, 2]},
          {_id: 11, k: 1, x: 3},
          {_id: 12, k: 2, x: [2]},
      ]));
      // The base index on "x" is what marks "x" as non-array; without it the bug is gone.
      assert.commandWorked(base.createIndex({x: 1}));
      assert.commandWorked(base.createIndex({k: 1}));
      assert.commandWorked(foreign.createIndex({k: 1}));
      
      const pipeline = [
          {$lookup: {from: foreign.getName(), localField: "k", foreignField: "k", as: "f"}},
          {$unwind: "$f"},
          {$match: {"f.x": 2}},
      ];
      const expected = [
          {_id: 1, k: 1, x: 5, f: {_id: 10, k: 1, x: [1, 2]}},
          {_id: 2, k: 2, x: 6, f: {_id: 12, k: 2, x: [2]}},
      ];
      
      function run(label, joinOpt) {
          // The join optimizer needs path arrayness to prove join keys are not arrays,
          // so internalEnablePathArrayness stays on; turning it off disables joins too.
          assert.commandWorked(db.adminCommand({
              setParameter: 1,
              internalEnableJoinOptimization: joinOpt,
              internalEnablePathArrayness: true,
          }));
          const results = base.aggregate(pipeline).toArray();
          const explain = tojson(base.explain().aggregate(pipeline));
          const usedJoinOpt = /JOIN_EMBEDDING/.test(explain);
          jsTest.log.info("F001 run", {label, joinOpt, usedJoinOpt, n: results.length, results});
          return {results, usedJoinOpt};
      }
      
      const joinOff = run("join off", false);
      const joinOnWithBaseXIndex = run("join on, base x index", true);
      assert.commandWorked(base.dropIndex({x: 1}));
      const joinOnNoBaseXIndex = run("join on, no base x index", true);
      
      // The join-on runs must really use the join optimizer, or the comparison proves nothing.
      assert(!joinOff.usedJoinOpt, "join optimization should be off");
      assert(joinOnWithBaseXIndex.usedJoinOpt, "join optimization did not fire");
      assert(joinOnNoBaseXIndex.usedJoinOpt, "join optimization did not fire");
      
      assert.sameMembers(joinOff.results, expected, "join off");
      // Same join plan, but no arrayness metadata for "x" on the base collection.
      assert.sameMembers(joinOnNoBaseXIndex.results, expected, "join on, no base x index");
      // Fails at d4834185d93: returns [] because traverseF is elided for foreign "x".
      assert.sameMembers(joinOnWithBaseXIndex.results, expected, "join on, base x index");
      
      MongoRunner.stopMongod(conn);
      

      Results:

      Run Join optimizer used Rows (expected 2)
      join off no 2
      join on, no base index on x yes 2
      join on, base index on x yes (HASH_JOIN_EMBEDDING, INDEXED_NESTED_LOOP_JOIN_EMBEDDING) 0

      Dropping the base collection's x index is the only difference between the correct and the wrong run, and both use the join plan.

      Cause (from code reading)

      • lowerToSbePlanStageTree() in src/mongo/db/query/compiler/optimizer/join/executor.cpp builds the whole SBE tree with the base node's CanonicalQuery, so every node shares the base ExpressionContext.
      • generateCollScan() in src/mongo/db/query/stage_builder/sbe/gen_coll_scan.cpp enables path arrayness for collection scan filters, including the foreign node's scan.
      • src/mongo/db/query/stage_builder/sbe/gen_filter.cpp asks expCtx->canPathBeArrayForNss(path, expCtx->getNamespaceString()). That namespace is the base collection. The base reports x as non-array, so traverseF is elided for the foreign scan and array elements of the foreign x never match.

      Impact

      Silent wrong results when internalEnableJoinOptimization is true. The knob defaults to false.

      Related

      • SERVER-133286 asks which ExpressionContext reaches SBE lowering in the join optimizer. This is a concrete wrong-result case of that question.
      • SERVER-125100 (elide traverseF in collection scans) is a likely introduction point. Not bisected.
      • SERVER-127609, SERVER-130378: earlier fixes to join optimizer ExpressionContext handling. Both are in d4834185d93.

            Assignee:
            Evan Bergeron
            Reporter:
            Zixuan Zhuang
            Votes:
            0 Vote for this issue
            Watchers:
            5 Start watching this issue

              Created:
              Updated: