Skip to content

Commit b706c22

Browse files
authored
fix: GeoPoint distance queries fail with an internal server error on MongoDB 8.3 and later (#10572)
1 parent 1bdb6a4 commit b706c22

3 files changed

Lines changed: 153 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,10 @@ jobs:
187187
MONGODB_VERSION: 8.0.4
188188
MONGODB_TOPOLOGY: replset
189189
NODE_VERSION: 24.11.0
190+
- name: MongoDB 8.3, ReplicaSet
191+
MONGODB_VERSION: 8.3.4
192+
MONGODB_TOPOLOGY: replset
193+
NODE_VERSION: 24.11.0
190194
- name: Redis Cache
191195
PARSE_SERVER_TEST_CACHE: redis
192196
MONGODB_VERSION: 8.0.4

spec/MongoCollection.spec.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
'use strict';
2+
3+
const { MongoClient } = require('mongodb');
4+
const MongoCollection = require('../lib/Adapters/Storage/Mongo/MongoCollection').default;
5+
const { findGeoIndexField } = require('../lib/Adapters/Storage/Mongo/MongoCollection');
6+
7+
describe_only_db('mongo')('MongoCollection', () => {
8+
describe('findGeoIndexField', () => {
9+
it('extracts the field constrained by $nearSphere', () => {
10+
const query = { construct: 'line', location: { $nearSphere: [-121.5, 38.5], $maxDistance: 2.5 } };
11+
expect(findGeoIndexField(query)).toBe('location');
12+
});
13+
14+
it('extracts the field constrained by $near', () => {
15+
expect(findGeoIndexField({ region: { $near: [0, 0] } })).toBe('region');
16+
});
17+
18+
it('recurses into $and to find the geo field', () => {
19+
const query = { $and: [{ a: 1 }, { loc: { $nearSphere: [0, 0] } }] };
20+
expect(findGeoIndexField(query)).toBe('loc');
21+
});
22+
23+
it('returns undefined when there is no geo operator', () => {
24+
expect(findGeoIndexField({ a: 1, b: { $gt: 2 } })).toBeUndefined();
25+
});
26+
27+
it('returns undefined for empty / non-object queries', () => {
28+
expect(findGeoIndexField({})).toBeUndefined();
29+
expect(findGeoIndexField(null)).toBeUndefined();
30+
expect(findGeoIndexField(undefined)).toBeUndefined();
31+
});
32+
33+
it('does not treat $geoWithin as requiring an index', () => {
34+
const query = { location: { $geoWithin: { $centerSphere: [[0, 0], 1] } } };
35+
expect(findGeoIndexField(query)).toBeUndefined();
36+
});
37+
38+
it('does not recurse into $or (MongoDB forbids $near inside $or)', () => {
39+
const query = { $or: [{ a: 1 }, { loc: { $nearSphere: [0, 0] } }] };
40+
expect(findGeoIndexField(query)).toBeUndefined();
41+
});
42+
});
43+
44+
describe('lazy geo index creation', () => {
45+
const collectionName = 'MongoCollectionLazyGeoIndexTest';
46+
let client;
47+
let rawCollection;
48+
49+
const geoQuery = { location: { $nearSphere: [-121.5, 38.5], $maxDistance: 2.526 } };
50+
51+
beforeEach(async () => {
52+
client = new MongoClient(databaseURI);
53+
await client.connect();
54+
rawCollection = client.db().collection(collectionName);
55+
// Start from a clean collection with NO geo index so the lazy-creation path is exercised.
56+
await rawCollection.drop().catch(() => {});
57+
await rawCollection.insertMany([
58+
{ _id: '1', location: [-121, 38] },
59+
{ _id: '2', location: [-122, 39] },
60+
]);
61+
});
62+
63+
afterEach(async () => {
64+
await rawCollection.drop().catch(() => {});
65+
await client.close();
66+
});
67+
68+
it('creates a 2d index on demand and returns results for a $nearSphere query on an un-indexed field', async () => {
69+
const mongoCollection = new MongoCollection(rawCollection);
70+
const results = await mongoCollection.find(geoQuery);
71+
expect(results.length).toBe(2);
72+
const indexes = await rawCollection.indexes();
73+
const hasGeoIndex = indexes.some(index => index.key && index.key.location === '2d');
74+
expect(hasGeoIndex).toBe(true);
75+
});
76+
77+
it_only_mongodb_version('>=8.3')('MongoDB 8.3+ reports the geoNear "no index" error without the field name', async () => {
78+
let error;
79+
try {
80+
await rawCollection.find(geoQuery).toArray();
81+
} catch (e) {
82+
error = e;
83+
}
84+
expect(error).toBeDefined();
85+
expect(error.message).toMatch(/unable to find index for .geoNear/);
86+
expect(error.message).not.toMatch(/field=/);
87+
});
88+
89+
it_only_mongodb_version('<8.3')('older MongoDB reports the geoNear "no index" error with the field name', async () => {
90+
let error;
91+
try {
92+
await rawCollection.find(geoQuery).toArray();
93+
} catch (e) {
94+
error = e;
95+
}
96+
expect(error).toBeDefined();
97+
expect(error.message).toMatch(/unable to find index for .geoNear/);
98+
expect(error.message).toMatch(/field=location/);
99+
});
100+
});
101+
});

src/Adapters/Storage/Mongo/MongoCollection.js

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,48 @@
11
const mongodb = require('mongodb');
22
const Collection = mongodb.Collection;
33

4+
// Query operators that require a geospatial index and therefore trigger
5+
// on-demand `2d` index creation. `$geoWithin` / `$geoIntersects` are intentionally
6+
// excluded: they can run as a collection scan and never raise a "no index" error.
7+
const GEO_INDEX_QUERY_OPERATORS = ['$nearSphere', '$near', '$geoNear'];
8+
9+
// Find the field in a Mongo query document that is constrained by a geo operator
10+
// requiring a geospatial index. Returns the field name (e.g. 'location'), or
11+
// undefined if none is found. Used as the reliable source of truth for on-demand
12+
// geo index creation, since the MongoDB error message that used to carry the field
13+
// name (`... field=<name> ...`) was dropped in MongoDB 8.3+.
14+
//
15+
// A geo-near expression must be top-level or inside `$and`: MongoDB rejects it inside
16+
// `$or` / `$nor` ("geo $near must be top-level expr") and forbids more than one per
17+
// query ("Too many geoNear expressions"). So there is at most one field to find, and
18+
// `$and` is the only combinator we need to recurse into.
19+
export function findGeoIndexField(query) {
20+
if (!query || typeof query !== 'object') {
21+
return undefined;
22+
}
23+
for (const field of Object.keys(query)) {
24+
const value = query[field];
25+
// Recurse into `$and`, which holds an array of sub-queries.
26+
if (field === '$and' && Array.isArray(value)) {
27+
for (const subQuery of value) {
28+
const found = findGeoIndexField(subQuery);
29+
if (found) {
30+
return found;
31+
}
32+
}
33+
continue;
34+
}
35+
if (
36+
value &&
37+
typeof value === 'object' &&
38+
GEO_INDEX_QUERY_OPERATORS.some(op => Object.prototype.hasOwnProperty.call(value, op))
39+
) {
40+
return field;
41+
}
42+
}
43+
return undefined;
44+
}
45+
446
export default class MongoCollection {
547
_mongoCollection: Collection;
648

@@ -51,8 +93,12 @@ export default class MongoCollection {
5193
if (error.code != 17007 && !error.message.match(/unable to find index for .geoNear/)) {
5294
throw error;
5395
}
54-
// Figure out what key needs an index
55-
const key = error.message.match(/field=([A-Za-z_0-9]+) /)[1];
96+
// Figure out which field needs a geo index.
97+
// Older MongoDB embeds the field name in the error message (`... field=<name> ...`);
98+
// MongoDB 8.3+ shortened the message to `unable to find index for $geoNear query`
99+
// and no longer includes it, so fall back to reading the field from the query itself.
100+
const messageMatch = error.message.match(/field=([A-Za-z_0-9]+) /);
101+
const key = (messageMatch && messageMatch[1]) || findGeoIndexField(query);
56102
if (!key) {
57103
throw error;
58104
}

0 commit comments

Comments
 (0)