Skip to content

MongoDB: broad :missing=true on missing_params metadata scans every index key and runs a $lookup per match (slower than the value route) #2233

Open

Description

@smunini

Summary

On MongoDB, a :missing=true search answered from missing_params metadata (#1984) is much slower than the value-index route when many resources match. The cause is the shape of the query, not the metadata. The metadata route should never be slower than the route it replaces.

Current behavior

Measured on MongoDB 8.0, single-node replica set, 1 GB WiredTiger cache, 300k Synthea resources (121,890 Observations), on main 5d92c68a5. The values are the median of 7 warm runs, page with an accurate total. "Off" means HFS_MONGODB_MISSING_METADATA=false from #2209, which uses the value-index route.

query metadata on metadata off
Observation specimen:missing=true (121,890 matches) 9.4 s 0.35–0.56 s
Observation value-quantity:missing=true (27,059) 1.9 s 0.40 s
Procedure reason-code:missing=true (30,430) 2.1 s 0.08 s
Condition abatement-date:missing=true (2,010) 140 ms 35 ms
Encounter reason-code:missing=true (5,144) 0.35–0.50 s 0.04–0.06 s
Observation encounter:missing=true (0 matches) 2 ms 3.0–3.7 s

Counts (_summary=count) behave the same. Totals are identical on both routes. #2206 does not change these numbers, because none of these queries has a positive filter to drive from.

Evidence

The count for specimen:missing=true, from the mongod slow-query log:

planSummary: IXSCAN { tenant_id: 1, resource_type: 1, is_deleted: 1, missing_params: 1, last_updated: 1, id: 1 }
keysExamined: 3014906  docsExamined: 121890  nreturned: 1  durationMillis: 9115
pipeline: [ {$match: {$and: [ {tenant_id, resource_type, is_deleted: false},
                               {$or: [ {missing_params: "specimen"}, {missing_params: {$exists: false}} ]} ]}},
            {$lookup: {from: "search_index", let: {candidate_id: {$cond: [...missing → $id, else false]}}, ...}},
            {$match: {"_hfs_missing_present.0": {$exists: false}}}, {$count: "total"} ]
hint: idx_resources_missing_params

There are two separate costs:

  1. Unbounded index scan, about 1.0–1.3 s. missing_resource_condition (crates/persistence/src/backends/mongodb/search_impl.rs:658) produces an $or that is nested under the scope $and. With the hint, the planner builds a single IXSCAN with missing_params: [MinKey, MaxKey] and filters in FETCH. That reads every key of the type (3.0M keys for 121,890 Observations, about 25 per document) and fetches every match.
  2. Per-document $lookup, about 8 s. missing_fallback_stages (search_impl.rs:666) runs its $lookup for every candidate. That includes documents that have an array, where candidate_id is false and the subpipeline can never match. It is still one subquery per document.

The same predicates in mongosh against that database:

countDocuments({...scope, missing_params: "specimen"})                       58 ms   (covered)
$and:[scope, {$or:[{missing_params:"specimen"},{missing_params:{$exists:false}}]}]   1292 ms (3.0M keys)
$or:[{...scope, missing_params:"specimen"}, {...scope, missing_params:{$exists:false}}]  91 ms (121,890 keys)

Expected behavior

The cost of a metadata-route :missing=true search or count scales with the number of matches, not with the number of index keys of the type. It should be at least as fast as the value-index route for broad matches, and keep its current advantage when few resources match (2 ms vs 3 s).

Suggested approach

  • Root the $or: copy the scope (tenant_id, resource_type, is_deleted) into each branch, so {missing_params: X} and {missing_params: {$exists: false}} each get tight bounds. missing_metadata_hint (search_impl.rs:638) already accepts an $or whose branches all reference missing_params.
  • Only send documents without an array to the $lookup fallback. For example, split the pipeline (or $facet/$unionWith) so the missing_params: X branch skips the lookup entirely. Or, when the freshness probe (mongodb: detect SearchParameter definition changes and invalidate stale missing_params automatically #1999) already shows that every live document of the type is stamped, drop the $exists: false branch and the lookup.
  • Add a plan-shape test asserting keysExamined is close to the match count for a broad absence. Use create_backend_with_stable_probes.

Scope / out of scope

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions