-
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).
- is related to
-
DRIVERS-3682 Add logic to convert change stream doc key to query filter
-
- Needs Triage
-