-
Type:
Bug
-
Resolution: Duplicate
-
Priority:
Major - P3
-
None
-
Affects Version/s: None
-
Component/s: None
-
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 multi-version performance oracle and the repro below confirmed the issue.
Problem
When the localField of a $lookup is an array with duplicate values and SBE runs the join as a HashJoin, each outer row probes the hash table once per array element, duplicates included. Each probe appends all matching foreign buffer indices to one vector, which is then sorted and de-duplicated. For an array of D elements whose values each match M foreign documents, each outer row inserts and sorts D x M indices. 9.0 converted the local key to a set first, so it probes once per distinct value. With default settings (trySbeRestricted), a $lookup on an unindexed foreign field runs in SBE as a HashJoin, so this shape runs in SBE.
Repro
Fails at master d0787dfc0ca (2026-10-08 nightly) and passes on 9.0.2. 25 local documents, each with a 2000-element array holding one repeated value; 10000 foreign documents over 10 distinct keys, no index on the join field:
/** * F008: SBE hash-join $lookup probes the hash table once per element of the local array key, * including duplicates, instead of once per distinct value. A local array with many duplicate * elements makes each outer row do thousands of probes and sort the combined matches. * * @tags: [ * requires_fcv_90, * requires_sbe * ] */ const conn = MongoRunner.runMongod({setParameter: {internalQueryDisablePlanCache: true}}); const db = conn.getDB(jsTestName()); // 10000 foreign docs over 10 distinct keys; no index on the join field, so SBE picks a hash join. let batch = []; for (let i = 0; i < 10000; i++) batch.push({_id: i, fk: i % 10, other: i}); assert.commandWorked(db.foreign.insert(batch)); // Each local doc has a 2000-element array holding a single repeated value. batch = []; for (let i = 0; i < 25; i++) batch.push({_id: i, lk: Array(2000).fill(i % 10)}); assert.commandWorked(db.local.insert(batch)); const pipeline = [{$lookup: {from: "foreign", localField: "lk", foreignField: "fk", as: "m"}}, {$count: "c"}]; function run(mode) { assert.commandWorked(db.adminCommand({setParameter: 1, internalQueryFrameworkControl: mode})); const s = tojson(db.local.explain().aggregate(pipeline)); const engine = s.includes("slotBasedPlan") ? "sbe" : "classic"; const strategy = (s.match(/"strategy" : "([A-Za-z]+)"/) || [])[1]; db.local.aggregate(pipeline).toArray(); const t = []; for (let i = 0; i < 5; i++) { const t0 = Date.now(); assert.eq(db.local.aggregate(pipeline).toArray()[0].c, 25); t.push(Date.now() - t0); } return {mode, engine, strategy, ms: t.sort((a, b) => a - b)[2]}; } const classic = run("forceClassicEngine"); const sbe = run("trySbeRestricted"); jsTest.log.info("F008 timings", {classic, sbe, ratio: sbe.ms / Math.max(classic.ms, 1)}); // Controls: the two settings run the two engines, and SBE uses the hash join. assert.eq(classic.engine, "classic", classic); assert.eq(sbe.engine, "sbe", sbe); assert.eq(sbe.strategy, "HashJoin", sbe); // Fails at master d0787dfc0ca (SBE about 18x slower than classic); passes on 9.0.2, where // SBE is faster than classic for this shape. assert.lt(sbe.ms / Math.max(classic.ms, 1), 3, {classic, sbe}); MongoRunner.stopMongod(conn);
Results of the repro (median of 5 runs, single mongod, plan cache off):
| Build | classic | SBE (default, HashJoin) | SBE / classic |
|---|---|---|---|
| master d0787dfc0ca | 109 ms | 2013 ms | 18.5x |
| 9.0.2 | 123 ms | 22 ms | 0.18x |
The same shape on all builds at once (enterprise amazon2023 aarch64, one host, runs interleaved, foreign collection fixed at 10000 documents):
| Local docs | master d0787dfc0ca SBE | 9.0.2 SBE | 8.3.11 SBE | 8.0.32 SBE | classic, master |
|---|---|---|---|---|---|
| 25 | 2019 ms | 23 ms | 22 ms | 30 ms | 109 ms |
| 100 | 8034 ms | 59 ms | 57 ms | 69 ms | 434 ms |
Master is 88-140x slower than 9.0.2 and 18.5x slower than classic on master. Bootstrap intervals are within 2% of each median. Results match on every side.
Cause (from code reading, not bisected)
- buildKeySetForLocal() in src/mongo/db/query/stage_builder/sbe/gen_lookup.cpp applies kArrayToSet only for KeyType::kArraySet. The comment says the de-duplication "can be skipped on the hash-join path, because the hash lookup implementation already de-duplicates the keys while probing". That came in with SERVER-132094 (ecb3471b014, 2026-07-29).
- LookupHashTableIter::initSearchArray() in src/mongo/db/exec/sbe/stages/lookup_hash_table.cpp de-duplicates the matched buffer indices, not the keys. It calls find() once per array element and appends every match to _hashTableMatchVector, then runs std::sort and std::unique over the whole vector.
SERVER-132095(de2737d124a, 2026-07-29) replaced the std::set here with this vector, so the vector now holds all D x M entries before the sort. - In the repro, D = 2000 and M = 1000, so each outer row sorts 2M indices to produce 1000.
Neither commit is on v9.0, which matches the release results. The code path is unchanged on master e3b5216b2ce.
Impact
$lookup with an array localField that holds repeated values (tags, category ids, event types) runs on the default engine at a cost proportional to the array length times the matches per value, instead of the number of distinct values. Arrays without duplicates are not affected. The regression is on master only and has not shipped in a release. No sys-perf workload covers a hash-join $lookup with duplicate array keys.
- related to
-
SERVER-132094 SBE $lookup: skip per-row arrayToSet on the outer (probe) key in the hash-join path
-
- Open
-
-
SERVER-132095 SBE $lookup: reuse a sorted vector instead of a per-row std::set for hash-join match indices
-
- Closed
-