perf(db-mongodb): faster join field aggregation by replacing mongoose-aggregate-paginate-v2 with a custom implementation (#10936)
Fixes https://github.com/payloadcms/payload/discussions/10165#discussioncomment-12034047 As described in the discussion, we have an incorrect order of aggregation pipeline when using aggregations with the join field. We must use `$sort`, `$skip`, `$limit` before the `$lookup` or otherwise mongodb scans all the docs, applies `$lookup` for them and only after applies `$limit`, `$skip`. Replaces `mongoose-aggregate-paginate-v2` with a custom `aggregatePaginate` because we need a custom solution here. This was considered in https://github.com/payloadcms/payload/pull/9594 but it was reverted as for now. Fixes https://github.com/payloadcms/payload/issues/11187
This commit is contained in:
@@ -47,14 +47,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"mongoose": "8.9.5",
|
||||
"mongoose-aggregate-paginate-v2": "1.1.2",
|
||||
"mongoose-paginate-v2": "1.8.5",
|
||||
"prompts": "2.4.2",
|
||||
"uuid": "10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/eslint-config": "workspace:*",
|
||||
"@types/mongoose-aggregate-paginate-v2": "1.0.12",
|
||||
"mongodb": "6.12.0",
|
||||
"mongodb-memory-server": "^10",
|
||||
"payload": "workspace:*"
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { buildSortParam } from './queries/buildSortParam.js'
|
||||
import { aggregatePaginate } from './utilities/aggregatePaginate.js'
|
||||
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
@@ -128,7 +129,20 @@ export const find: Find = async function find(
|
||||
})
|
||||
// build join aggregation
|
||||
if (aggregate) {
|
||||
result = await Model.aggregatePaginate(Model.aggregate(aggregate), paginationOptions)
|
||||
result = await aggregatePaginate({
|
||||
adapter: this,
|
||||
collation: paginationOptions.collation,
|
||||
joinAggregation: aggregate,
|
||||
limit: paginationOptions.limit,
|
||||
Model,
|
||||
page: paginationOptions.page,
|
||||
pagination: paginationOptions.pagination,
|
||||
projection: paginationOptions.projection,
|
||||
query,
|
||||
session: paginationOptions.options?.session,
|
||||
sort: paginationOptions.sort as object,
|
||||
useEstimatedCount: paginationOptions.useEstimatedCount,
|
||||
})
|
||||
} else {
|
||||
result = await Model.paginate(query, paginationOptions)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FindOne } from 'payload'
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { aggregatePaginate } from './utilities/aggregatePaginate.js'
|
||||
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
@@ -40,7 +41,6 @@ export const findOne: FindOne = async function findOne(
|
||||
collection,
|
||||
collectionConfig,
|
||||
joins,
|
||||
limit: 1,
|
||||
locale,
|
||||
projection,
|
||||
query,
|
||||
@@ -48,7 +48,17 @@ export const findOne: FindOne = async function findOne(
|
||||
|
||||
let doc
|
||||
if (aggregate) {
|
||||
;[doc] = await Model.aggregate(aggregate, { session })
|
||||
const { docs } = await aggregatePaginate({
|
||||
adapter: this,
|
||||
joinAggregation: aggregate,
|
||||
limit: 1,
|
||||
Model,
|
||||
pagination: false,
|
||||
projection,
|
||||
query,
|
||||
session,
|
||||
})
|
||||
doc = docs[0]
|
||||
} else {
|
||||
;(options as Record<string, unknown>).projection = projection
|
||||
doc = await Model.findOne(query, {}, options)
|
||||
|
||||
@@ -92,7 +92,10 @@ export interface Args {
|
||||
|
||||
/** Extra configuration options */
|
||||
connectOptions?: {
|
||||
/** Set false to disable $facet aggregation in non-supporting databases, Defaults to true */
|
||||
/**
|
||||
* Set false to disable $facet aggregation in non-supporting databases, Defaults to true
|
||||
* @deprecated Payload doesn't use `$facet` anymore anywhere.
|
||||
*/
|
||||
useFacet?: boolean
|
||||
} & ConnectOptions
|
||||
/** Set to true to disable hinting to MongoDB to use 'id' as index. This is currently done when counting documents for pagination. Disabling this optimization might fix some problems with AWS DocumentDB. Defaults to false */
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { PaginateOptions } from 'mongoose'
|
||||
import type { Init, SanitizedCollectionConfig } from 'payload'
|
||||
|
||||
import mongoose from 'mongoose'
|
||||
import mongooseAggregatePaginate from 'mongoose-aggregate-paginate-v2'
|
||||
import paginate from 'mongoose-paginate-v2'
|
||||
import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload'
|
||||
|
||||
@@ -48,10 +47,6 @@ export const init: Init = function init(this: MongooseAdapter) {
|
||||
}),
|
||||
)
|
||||
|
||||
if (Object.keys(collection.joins).length > 0) {
|
||||
versionSchema.plugin(mongooseAggregatePaginate)
|
||||
}
|
||||
|
||||
const versionCollectionName =
|
||||
this.autoPluralization === true && !collection.dbName ? undefined : versionModelName
|
||||
|
||||
@@ -59,14 +54,14 @@ export const init: Init = function init(this: MongooseAdapter) {
|
||||
versionModelName,
|
||||
versionSchema,
|
||||
versionCollectionName,
|
||||
) as CollectionModel
|
||||
) as unknown as CollectionModel
|
||||
}
|
||||
|
||||
const modelName = getDBName({ config: collection })
|
||||
const collectionName =
|
||||
this.autoPluralization === true && !collection.dbName ? undefined : modelName
|
||||
|
||||
this.collections[collection.slug] = mongoose.model(
|
||||
this.collections[collection.slug] = mongoose.model<any>(
|
||||
modelName,
|
||||
schema,
|
||||
collectionName,
|
||||
@@ -101,7 +96,7 @@ export const init: Init = function init(this: MongooseAdapter) {
|
||||
}),
|
||||
)
|
||||
|
||||
this.versions[global.slug] = mongoose.model(
|
||||
this.versions[global.slug] = mongoose.model<any>(
|
||||
versionModelName,
|
||||
versionSchema,
|
||||
versionModelName,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { PaginateOptions, Schema } from 'mongoose'
|
||||
import type { Payload, SanitizedCollectionConfig } from 'payload'
|
||||
|
||||
import mongooseAggregatePaginate from 'mongoose-aggregate-paginate-v2'
|
||||
import paginate from 'mongoose-paginate-v2'
|
||||
|
||||
import { getBuildQueryPlugin } from '../queries/getBuildQueryPlugin.js'
|
||||
@@ -44,12 +43,5 @@ export const buildCollectionSchema = (
|
||||
.plugin<any, PaginateOptions>(paginate, { useEstimatedCount: true })
|
||||
.plugin(getBuildQueryPlugin({ collectionSlug: collection.slug }))
|
||||
|
||||
if (
|
||||
Object.keys(collection.joins).length > 0 ||
|
||||
Object.keys(collection.polymorphicJoins).length > 0
|
||||
) {
|
||||
schema.plugin(mongooseAggregatePaginate)
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { buildSortParam } from './queries/buildSortParam.js'
|
||||
import { aggregatePaginate } from './utilities/aggregatePaginate.js'
|
||||
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
@@ -116,10 +117,20 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
|
||||
|
||||
// build join aggregation
|
||||
if (aggregate) {
|
||||
result = await VersionModel.aggregatePaginate(
|
||||
VersionModel.aggregate(aggregate),
|
||||
paginationOptions,
|
||||
)
|
||||
result = await aggregatePaginate({
|
||||
adapter: this,
|
||||
collation: paginationOptions.collation,
|
||||
joinAggregation: aggregate,
|
||||
limit: paginationOptions.limit,
|
||||
Model: VersionModel,
|
||||
page: paginationOptions.page,
|
||||
pagination: paginationOptions.pagination,
|
||||
projection: paginationOptions.projection,
|
||||
query: versionQuery,
|
||||
session: paginationOptions.options?.session,
|
||||
sort: paginationOptions.sort as object,
|
||||
useEstimatedCount: paginationOptions.useEstimatedCount,
|
||||
})
|
||||
} else {
|
||||
result = await VersionModel.paginate(versionQuery, paginationOptions)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { ClientSession } from 'mongodb'
|
||||
import type {
|
||||
AggregatePaginateModel,
|
||||
IndexDefinition,
|
||||
IndexOptions,
|
||||
Model,
|
||||
PaginateModel,
|
||||
SchemaOptions,
|
||||
} from 'mongoose'
|
||||
import type { IndexDefinition, IndexOptions, Model, PaginateModel, SchemaOptions } from 'mongoose'
|
||||
import type {
|
||||
ArrayField,
|
||||
BlocksField,
|
||||
@@ -37,10 +30,7 @@ import type {
|
||||
|
||||
import type { BuildQueryArgs } from './queries/getBuildQueryPlugin.js'
|
||||
|
||||
export interface CollectionModel
|
||||
extends Model<any>,
|
||||
PaginateModel<any>,
|
||||
AggregatePaginateModel<any> {
|
||||
export interface CollectionModel extends Model<any>, PaginateModel<any> {
|
||||
/** buildQuery is used to transform payload's where operator into what can be used by mongoose (e.g. id => _id) */
|
||||
buildQuery: (args: BuildQueryArgs) => Promise<Record<string, unknown>> // TODO: Delete this
|
||||
}
|
||||
|
||||
104
packages/db-mongodb/src/utilities/aggregatePaginate.ts
Normal file
104
packages/db-mongodb/src/utilities/aggregatePaginate.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { CollationOptions } from 'mongodb'
|
||||
import type { ClientSession, Model, PipelineStage } from 'mongoose'
|
||||
import type { PaginatedDocs } from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from '../index.js'
|
||||
|
||||
export const aggregatePaginate = async ({
|
||||
adapter,
|
||||
collation,
|
||||
joinAggregation,
|
||||
limit,
|
||||
Model,
|
||||
page,
|
||||
pagination,
|
||||
projection,
|
||||
query,
|
||||
session,
|
||||
sort,
|
||||
useEstimatedCount,
|
||||
}: {
|
||||
adapter: MongooseAdapter
|
||||
collation?: CollationOptions
|
||||
joinAggregation?: PipelineStage[]
|
||||
limit?: number
|
||||
Model: Model<any>
|
||||
page?: number
|
||||
pagination?: boolean
|
||||
projection?: Record<string, boolean>
|
||||
query: Record<string, unknown>
|
||||
session?: ClientSession
|
||||
sort?: object
|
||||
useEstimatedCount?: boolean
|
||||
}): Promise<PaginatedDocs<any>> => {
|
||||
const aggregation: PipelineStage[] = [{ $match: query }]
|
||||
|
||||
if (sort) {
|
||||
const $sort: Record<string, -1 | 1> = {}
|
||||
|
||||
Object.entries(sort).forEach(([key, value]) => {
|
||||
$sort[key] = value === 'desc' ? -1 : 1
|
||||
})
|
||||
|
||||
aggregation.push({ $sort })
|
||||
}
|
||||
|
||||
if (page) {
|
||||
aggregation.push({ $skip: (page - 1) * (limit ?? 0) })
|
||||
}
|
||||
|
||||
if (limit) {
|
||||
aggregation.push({ $limit: limit })
|
||||
}
|
||||
|
||||
if (joinAggregation) {
|
||||
for (const stage of joinAggregation) {
|
||||
aggregation.push(stage)
|
||||
}
|
||||
}
|
||||
|
||||
if (projection) {
|
||||
aggregation.push({ $project: projection })
|
||||
}
|
||||
|
||||
let countPromise: Promise<null | number> = Promise.resolve(null)
|
||||
|
||||
if (pagination !== false && limit) {
|
||||
if (useEstimatedCount) {
|
||||
countPromise = Model.estimatedDocumentCount(query)
|
||||
} else {
|
||||
const hint = adapter.disableIndexHints !== true ? { _id: 1 } : undefined
|
||||
countPromise = Model.countDocuments(query, { collation, hint, session })
|
||||
}
|
||||
}
|
||||
|
||||
const [docs, countResult] = await Promise.all([
|
||||
Model.aggregate(aggregation, { collation, session }),
|
||||
countPromise,
|
||||
])
|
||||
|
||||
const count = countResult === null ? docs.length : countResult
|
||||
|
||||
const totalPages =
|
||||
pagination !== false && typeof limit === 'number' && limit !== 0 ? Math.ceil(count / limit) : 1
|
||||
|
||||
const hasPrevPage = pagination !== false && page > 1
|
||||
const hasNextPage = pagination !== false && totalPages > page
|
||||
const pagingCounter =
|
||||
pagination !== false && typeof limit === 'number' ? (page - 1) * limit + 1 : 1
|
||||
|
||||
const result: PaginatedDocs = {
|
||||
docs,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
limit,
|
||||
nextPage: hasNextPage ? page + 1 : null,
|
||||
page,
|
||||
pagingCounter,
|
||||
prevPage: hasPrevPage ? page - 1 : null,
|
||||
totalDocs: count,
|
||||
totalPages,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -19,8 +19,6 @@ type BuildJoinAggregationArgs = {
|
||||
collection: CollectionSlug
|
||||
collectionConfig: SanitizedCollectionConfig
|
||||
joins: JoinQuery
|
||||
// the number of docs to get at the top collection level
|
||||
limit?: number
|
||||
locale: string
|
||||
projection?: Record<string, true>
|
||||
// the where clause for the top collection
|
||||
@@ -34,10 +32,8 @@ export const buildJoinAggregation = async ({
|
||||
collection,
|
||||
collectionConfig,
|
||||
joins,
|
||||
limit,
|
||||
locale,
|
||||
projection,
|
||||
query,
|
||||
versions,
|
||||
}: BuildJoinAggregationArgs): Promise<PipelineStage[] | undefined> => {
|
||||
if (
|
||||
@@ -49,24 +45,8 @@ export const buildJoinAggregation = async ({
|
||||
}
|
||||
|
||||
const joinConfig = adapter.payload.collections[collection].config.joins
|
||||
const aggregate: PipelineStage[] = []
|
||||
const polymorphicJoinsConfig = adapter.payload.collections[collection].config.polymorphicJoins
|
||||
const aggregate: PipelineStage[] = [
|
||||
{
|
||||
$sort: { createdAt: -1 },
|
||||
},
|
||||
]
|
||||
|
||||
if (query) {
|
||||
aggregate.push({
|
||||
$match: query,
|
||||
})
|
||||
}
|
||||
|
||||
if (limit) {
|
||||
aggregate.push({
|
||||
$limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
for (const join of polymorphicJoinsConfig) {
|
||||
if (projection && !projection[join.joinPath]) {
|
||||
@@ -448,9 +428,5 @@ export const buildJoinAggregation = async ({
|
||||
}
|
||||
}
|
||||
|
||||
if (projection) {
|
||||
aggregate.push({ $project: projection })
|
||||
}
|
||||
|
||||
return aggregate
|
||||
}
|
||||
|
||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -251,9 +251,6 @@ importers:
|
||||
mongoose:
|
||||
specifier: 8.9.5
|
||||
version: 8.9.5(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3)
|
||||
mongoose-aggregate-paginate-v2:
|
||||
specifier: 1.1.2
|
||||
version: 1.1.2
|
||||
mongoose-paginate-v2:
|
||||
specifier: 1.8.5
|
||||
version: 1.8.5
|
||||
@@ -267,9 +264,6 @@ importers:
|
||||
'@payloadcms/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../eslint-config
|
||||
'@types/mongoose-aggregate-paginate-v2':
|
||||
specifier: 1.0.12
|
||||
version: 1.0.12(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3)
|
||||
mongodb:
|
||||
specifier: 6.12.0
|
||||
version: 6.12.0(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3)
|
||||
@@ -5355,9 +5349,6 @@ packages:
|
||||
'@types/minimist@1.2.5':
|
||||
resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==}
|
||||
|
||||
'@types/mongoose-aggregate-paginate-v2@1.0.12':
|
||||
resolution: {integrity: sha512-wL8pgJQxqJagv5f5mR7aI8WgUu22nS6rVLoJm71W2Uu+iKfS8jgph2rRLfXrjo+dFt1s7ik5Zl+uGZ4f5GM6Vw==}
|
||||
|
||||
'@types/ms@0.7.34':
|
||||
resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==}
|
||||
|
||||
@@ -8353,10 +8344,6 @@ packages:
|
||||
socks:
|
||||
optional: true
|
||||
|
||||
mongoose-aggregate-paginate-v2@1.1.2:
|
||||
resolution: {integrity: sha512-Ai478tHedZy3U2ITBEp2H4rQEviRan3TK4p/umlFqIzgPF1R0hNKvzzQGIb1l2h+Z32QLU3NqaoWKu4vOOUElQ==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
mongoose-paginate-v2@1.8.5:
|
||||
resolution: {integrity: sha512-kFxhot+yw9KmpAGSSrF/o+f00aC2uawgNUbhyaM0USS9L7dln1NA77/pLg4lgOaRgXMtfgCENamjqZwIM1Zrig==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
@@ -14808,20 +14795,6 @@ snapshots:
|
||||
|
||||
'@types/minimist@1.2.5': {}
|
||||
|
||||
'@types/mongoose-aggregate-paginate-v2@1.0.12(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3)':
|
||||
dependencies:
|
||||
'@types/node': 22.5.4
|
||||
mongoose: 8.9.5(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3)
|
||||
transitivePeerDependencies:
|
||||
- '@aws-sdk/credential-providers'
|
||||
- '@mongodb-js/zstd'
|
||||
- gcp-metadata
|
||||
- kerberos
|
||||
- mongodb-client-encryption
|
||||
- snappy
|
||||
- socks
|
||||
- supports-color
|
||||
|
||||
'@types/ms@0.7.34': {}
|
||||
|
||||
'@types/mysql@2.15.26':
|
||||
@@ -18496,8 +18469,6 @@ snapshots:
|
||||
'@aws-sdk/credential-providers': 3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0))
|
||||
socks: 2.8.3
|
||||
|
||||
mongoose-aggregate-paginate-v2@1.1.2: {}
|
||||
|
||||
mongoose-paginate-v2@1.8.5: {}
|
||||
|
||||
mongoose@8.9.5(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3):
|
||||
|
||||
Reference in New Issue
Block a user