ExportXMLWordPrintableJSON

    • Type: Bug
    • Resolution: Unresolved
    • Priority: Critical - P2
    • None
    • Affects Version/s: None
    • Component/s: None
    • DB Integration & Observability
    • ALL
    • v9.0, v8.3, v8.0, v7.0
    • QE 2026-10-26
    • None
    • None
    • None
    • None
    • None
    • None
    • None

      Summary

      A change stream opened with fullDocument: "updateLookup" can return the *wrong document* as the event's fullDocument.

      The full-document lookup builds its query from the event's documentKey (src/mongo/db/exec/single_doc_lookup/aggregation_single_document_lookup_executor.cpp, performLookup):

      escapedKey = containsDollarPrefixedFieldNamesOnTopLevel(documentKey)
          ? convertDocumentIntoQuery(documentKey)   // $expr/$getField/$literal form
          : documentKey                             // RAW document used as the query
      

      containsDollarPrefixedFieldNamesOnTopLevel() only inspects object values; a *regexp-valued* documentKey field is left verbatim in the find filter. There it (a) cannot be used for shard targeting, so the lookup *broadcasts, and (b) **pattern-matches* string values.

      When the regexp-valued document has been moved off its shard (its shard key was updated) by the time the lookup runs, it no longer matches its own key — and another document sharing the same _id on a different shard (legal: _id uniqueness is per-shard for non-_id shard keys) whose shard key field is a string matching the pattern becomes the *only* match. The lookup returns that neighbor's document as the event's fullDocument.

      Repro

      mongosh script attached (repro-update-lookup-regexp-fulldoc.js); deterministic, run against a 3-shard cluster:

      // chunks: [MinKey, "m"), ["m", "z"), ["z", MaxKey); regexps sort after all strings
      coll.insertOne({ _id: 1, foo: "abc" });   // bottom chunk (shard 0)
      coll.insertOne({ _id: 1, foo: /^a/ });    // top chunk (shard 2) — same _id, legal
      
      const cs = coll.watch([], { fullDocument: "updateLookup" });
      
      // 1) Plain update on the regexp document → update event, documentKey {foo: /^a/, _id: 1}
      coll.updateOne({ _id: 1, foo: { $eq: /^a/ } }, { $set: { marker: true } });
      // 2) Move it: "m2" lives in the middle chunk. The move surfaces as delete+insert events;
      //    the document no longer matches its own old key.
      coll.updateOne({ _id: 1, foo: { $eq: /^a/ } }, { $set: { foo: "m2" } });
      
      sleep(1000);
      printjson(cs.next());
      

      Observed:

      event operationType: update
      event documentKey: { foo: /^a/, _id: 1 }
      fullDocument returned by updateLookup:
      { _id: 1, foo: 'abc' }        // ← the NEIGHBOR's document, matched by pattern
      

      Expected: fullDocument is the updated document's post-image ({_id: 1, foo: /^a/, marker: true}) — or, once it has moved, null (document no longer matches its own key) — but never a different document.

      Root cause

      The escape gate before convertDocumentIntoQuery() handles only:

      • top-level field names starting with $, and
      • object values whose first sub-field starts with $.

      A regexp value is neither, so it is passed through verbatim into the find filter — where match semantics give it pattern-matching power over strings, unlike every other scalar type (numeric/string/date equality is value-based). Note convertDocumentIntoQuery() itself wraps '$'-shaped object values in {$eq} for exactly this reason; regexp values need the same treatment ({$eq: /re/} compares regexes by value, never by pattern).

      Impact

      Any consumer of fullDocument: "updateLookup" — drivers, Atlas Triggers, the Kafka connector — can silently receive the wrong document's body for an update event. No error is surfaced.

      Suggested fix

      Escape regexp-valued documentKey fields the same way $-shaped object values are escaped: wrap them in {$eq: <value>} (value equality) before using the documentKey as a find filter — either in containsDollarPrefixedFieldNamesOnTopLevel's gate or in convertDocumentIntoQuery().

      Environment

      Reproduced deterministically on a 3-shard cluster, version 8.0.16-353-gb19191c (internal build).

            Assignee:
            Jan Steemann
            Reporter:
            Felipe Gasper
            Votes:
            0 Vote for this issue
            Watchers:
            1 Start watching this issue

              Created:
              Updated: