-
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.
- is related to
-
SERVER-129249 Nested $unionWith inside of a $lookup drops view pipeline
-
- Closed
-
- related to
-
SERVER-131157 $lookup rejects $search view defs inside hybrid search
-
- Closed
-