fix: GeoPoint distance queries fail with an internal server error on MongoDB 8.3 and later (#10572)

This commit is contained in:
Manuel
2026-07-13 02:39:05 +02:00
committed by GitHub
parent 1bdb6a411f
commit b706c22cd9
3 changed files with 153 additions and 2 deletions
+4
View File
@@ -187,6 +187,10 @@ jobs:
MONGODB_VERSION: 8.0.4
MONGODB_TOPOLOGY: replset
NODE_VERSION: 24.11.0
- name: MongoDB 8.3, ReplicaSet
MONGODB_VERSION: 8.3.4
MONGODB_TOPOLOGY: replset
NODE_VERSION: 24.11.0
- name: Redis Cache
PARSE_SERVER_TEST_CACHE: redis
MONGODB_VERSION: 8.0.4
+101
View File
@@ -0,0 +1,101 @@
'use strict';
const { MongoClient } = require('mongodb');
const MongoCollection = require('../lib/Adapters/Storage/Mongo/MongoCollection').default;
const { findGeoIndexField } = require('../lib/Adapters/Storage/Mongo/MongoCollection');
describe_only_db('mongo')('MongoCollection', () => {
describe('findGeoIndexField', () => {
it('extracts the field constrained by $nearSphere', () => {
const query = { construct: 'line', location: { $nearSphere: [-121.5, 38.5], $maxDistance: 2.5 } };
expect(findGeoIndexField(query)).toBe('location');
});
it('extracts the field constrained by $near', () => {
expect(findGeoIndexField({ region: { $near: [0, 0] } })).toBe('region');
});
it('recurses into $and to find the geo field', () => {
const query = { $and: [{ a: 1 }, { loc: { $nearSphere: [0, 0] } }] };
expect(findGeoIndexField(query)).toBe('loc');
});
it('returns undefined when there is no geo operator', () => {
expect(findGeoIndexField({ a: 1, b: { $gt: 2 } })).toBeUndefined();
});
it('returns undefined for empty / non-object queries', () => {
expect(findGeoIndexField({})).toBeUndefined();
expect(findGeoIndexField(null)).toBeUndefined();
expect(findGeoIndexField(undefined)).toBeUndefined();
});
it('does not treat $geoWithin as requiring an index', () => {
const query = { location: { $geoWithin: { $centerSphere: [[0, 0], 1] } } };
expect(findGeoIndexField(query)).toBeUndefined();
});
it('does not recurse into $or (MongoDB forbids $near inside $or)', () => {
const query = { $or: [{ a: 1 }, { loc: { $nearSphere: [0, 0] } }] };
expect(findGeoIndexField(query)).toBeUndefined();
});
});
describe('lazy geo index creation', () => {
const collectionName = 'MongoCollectionLazyGeoIndexTest';
let client;
let rawCollection;
const geoQuery = { location: { $nearSphere: [-121.5, 38.5], $maxDistance: 2.526 } };
beforeEach(async () => {
client = new MongoClient(databaseURI);
await client.connect();
rawCollection = client.db().collection(collectionName);
// Start from a clean collection with NO geo index so the lazy-creation path is exercised.
await rawCollection.drop().catch(() => {});
await rawCollection.insertMany([
{ _id: '1', location: [-121, 38] },
{ _id: '2', location: [-122, 39] },
]);
});
afterEach(async () => {
await rawCollection.drop().catch(() => {});
await client.close();
});
it('creates a 2d index on demand and returns results for a $nearSphere query on an un-indexed field', async () => {
const mongoCollection = new MongoCollection(rawCollection);
const results = await mongoCollection.find(geoQuery);
expect(results.length).toBe(2);
const indexes = await rawCollection.indexes();
const hasGeoIndex = indexes.some(index => index.key && index.key.location === '2d');
expect(hasGeoIndex).toBe(true);
});
it_only_mongodb_version('>=8.3')('MongoDB 8.3+ reports the geoNear "no index" error without the field name', async () => {
let error;
try {
await rawCollection.find(geoQuery).toArray();
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toMatch(/unable to find index for .geoNear/);
expect(error.message).not.toMatch(/field=/);
});
it_only_mongodb_version('<8.3')('older MongoDB reports the geoNear "no index" error with the field name', async () => {
let error;
try {
await rawCollection.find(geoQuery).toArray();
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toMatch(/unable to find index for .geoNear/);
expect(error.message).toMatch(/field=location/);
});
});
});
+48 -2
View File
@@ -1,6 +1,48 @@
const mongodb = require('mongodb');
const Collection = mongodb.Collection;
// Query operators that require a geospatial index and therefore trigger
// on-demand `2d` index creation. `$geoWithin` / `$geoIntersects` are intentionally
// excluded: they can run as a collection scan and never raise a "no index" error.
const GEO_INDEX_QUERY_OPERATORS = ['$nearSphere', '$near', '$geoNear'];
// Find the field in a Mongo query document that is constrained by a geo operator
// requiring a geospatial index. Returns the field name (e.g. 'location'), or
// undefined if none is found. Used as the reliable source of truth for on-demand
// geo index creation, since the MongoDB error message that used to carry the field
// name (`... field=<name> ...`) was dropped in MongoDB 8.3+.
//
// A geo-near expression must be top-level or inside `$and`: MongoDB rejects it inside
// `$or` / `$nor` ("geo $near must be top-level expr") and forbids more than one per
// query ("Too many geoNear expressions"). So there is at most one field to find, and
// `$and` is the only combinator we need to recurse into.
export function findGeoIndexField(query) {
if (!query || typeof query !== 'object') {
return undefined;
}
for (const field of Object.keys(query)) {
const value = query[field];
// Recurse into `$and`, which holds an array of sub-queries.
if (field === '$and' && Array.isArray(value)) {
for (const subQuery of value) {
const found = findGeoIndexField(subQuery);
if (found) {
return found;
}
}
continue;
}
if (
value &&
typeof value === 'object' &&
GEO_INDEX_QUERY_OPERATORS.some(op => Object.prototype.hasOwnProperty.call(value, op))
) {
return field;
}
}
return undefined;
}
export default class MongoCollection {
_mongoCollection: Collection;
@@ -51,8 +93,12 @@ export default class MongoCollection {
if (error.code != 17007 && !error.message.match(/unable to find index for .geoNear/)) {
throw error;
}
// Figure out what key needs an index
const key = error.message.match(/field=([A-Za-z_0-9]+) /)[1];
// Figure out which field needs a geo index.
// Older MongoDB embeds the field name in the error message (`... field=<name> ...`);
// MongoDB 8.3+ shortened the message to `unable to find index for $geoNear query`
// and no longer includes it, so fall back to reading the field from the query itself.
const messageMatch = error.message.match(/field=([A-Za-z_0-9]+) /);
const key = (messageMatch && messageMatch[1]) || findGeoIndexField(query);
if (!key) {
throw error;
}