ExportXMLWordPrintableJSON

    • Type: Bug
    • Resolution: Unresolved
    • Priority: Critical - P2
    • None
    • Affects Version/s: None
    • Component/s: None
    • Query Optimization
    • 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

      View V1's definition contains a $lookup from view V2. A $lookup from V1 whose pipeline contains $rankFusion runs V2's stages twice, so a $set in V2 is applied twice. Reading V1 directly, through $unionWith, or through a $lookup without $rankFusion applies V2 once. It happens with one or more $rankFusion inputs. Default settings; no extensions.

      Repro

      Fails at master ba06f31d06c (2026-10-05). Passes at 6135c13099c (2026-06-02). Self-contained jstests/noPassthrough-style test:

      /**
       * $lookup with a $rankFusion pipeline from a view V1 whose definition $lookup's another view V2:
       * V2's stages run twice.
       *
       * @tags: [requires_fcv_90]
       */
      const conn = MongoRunner.runMongod({setParameter: {internalQueryDisablePlanCache: true}});
      const db = conn.getDB(jsTestName());
      
      assert.commandWorked(db.b.insert({_id: 1, n: 0}));
      assert.commandWorked(db.o.insert({_id: 1}));
      // V2 adds 1 to n, so every read through V2 must see n: 1.
      assert.commandWorked(db.createView("V2", "b", [{$set: {n: {$add: ["$n", 1]}}}]));
      assert.commandWorked(db.createView("V1", "b", [{$lookup: {from: "V2", as: "j", pipeline: [{$project: {n: 1}}]}}]));
      
      const rf2 = {$rankFusion: {input: {pipelines: {a: [{$sort: {_id: 1}}], c: [{$sort: {_id: -1}}]}}}};
      const rf1 = {$rankFusion: {input: {pipelines: {a: [{$sort: {_id: 1}}]}}}};
      function run(label, fn) {
          const got = fn();
          jsTest.log.info("D run", {label, n: got});
          return got;
      }
      const viaLookup = (pipeline) =>
          db.o.aggregate([{$lookup: {from: "V1", as: "r", pipeline: pipeline.concat([{$project: {j: 1}}])}}]).toArray()[0]
              .r[0].j[0].n;
      
      const direct = run("aggregate on V1", () => db.V1.aggregate([]).toArray()[0].j[0].n);
      const directRf = run("aggregate on V1 with 2-input $rankFusion", () => db.V1.aggregate([rf2]).toArray()[0].j[0].n);
      const plain = run("$lookup from V1, pipeline [$sort]", () => viaLookup([{$sort: {_id: 1}}]));
      const union = run("$unionWith V1 with 2-input $rankFusion", () =>
          db.o.aggregate([{$match: {_id: -1}}, {$unionWith: {coll: "V1", pipeline: [rf2]}}]).toArray()[0].j[0].n);
      const lk1 = run("$lookup from V1, 1-input $rankFusion", () => viaLookup([rf1]));
      const lk2 = run("$lookup from V1, 2-input $rankFusion", () => viaLookup([rf2]));
      
      // Controls: V2 is applied once on every other path.
      assert.eq(direct, 1, "aggregate on V1");
      assert.eq(directRf, 1, "aggregate on V1 with $rankFusion");
      assert.eq(plain, 1, "$lookup without $rankFusion");
      assert.eq(union, 1, "$unionWith with $rankFusion");
      // Fails at ba06f31d06c: n is 2; V2's $set ran twice.
      assert.eq(lk2, 1, "$lookup with 2-input $rankFusion");
      jsTest.log.info("D lk1 (1-input) for reference", {lk1});
      
      MongoRunner.stopMongod(conn);
      

      Results of the repro (V2 adds 1 to n, which starts at 0, so every read must see n: 1):

      Access to V1 n
      aggregate on V1 1
      aggregate on V1 with $rankFusion 1
      $lookup from V1 without $rankFusion 1
      $unionWith V1 with $rankFusion 1
      $lookup from V1 with $rankFusion (1 or 2 inputs) 2

      Cause (from code reading)

      • For a view plus hybrid search plus a source with a subpipeline, DocumentSourceLookUp replaces the resolved pipeline with resolvedIntrospectionPipeline->serializeToBson() using default serialization options (src/mongo/db/pipeline/document_source_lookup.cpp).
      • With default options the nested $lookup serializes from: V2 together with a pipeline that already contains V2's stages; on re-parse its constructor prepends V2's stages again.
      • kAlreadyBound (SERVER-131157) only skips the view binder; it does not stop the nested $lookup from prepending its view.
      • rebuildResolvedPipeline() in the same file serializes with serializeForFLE2 so nested $lookup stages serialize their resolved pipeline; the hybrid-search block does not.

      Impact

      Silent wrong results when a $lookup with $rankFusion reads a view whose definition contains a $lookup (or other subpipeline stage) on another view.

      Related

      • SERVER-131157: added kAlreadyBound to skip $lookup view re-binding.
      • SERVER-129249: added the re-serialization of the introspection pipeline for hybrid search.

            Assignee:
            Unassigned
            Reporter:
            Zixuan Zhuang
            Votes:
            0 Vote for this issue
            Watchers:
            1 Start watching this issue

              Created:
              Updated: