-
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.
- is related to
-
SERVER-127609 Codify join optimizer’s management of path arrayness metadata
-
- Closed
-
-
SERVER-130378 Pass targetNode's ExpCtx info when creating CanonicalQuery in predicate inference
-
- Closed
-
-
SERVER-125100 Elide traverseF in generic and clustered collection scans
-
- Closed
-
- related to
-
SERVER-136302 Retain traverseF in SBE $elemMatch MatchFrames
-
- Closed
-
-
SERVER-133286 Investige and codify ExpCtx handling in JOO
-
- Needs Scheduling
-