Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions packages/payload/src/collections/dataloader.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'

import { getDataLoader } from './dataloader.js'

const createDeferred = () => {
let resolve!: () => void
const promise = new Promise<void>((res) => {
resolve = res
})

return { promise, resolve }
}

describe('getDataLoader', () => {
it('should serialize find calls while a transaction is active', async () => {
const calls: string[] = []
const firstStarted = createDeferred()
const releaseFirst = createDeferred()
let activeFinds = 0
let maxActiveFinds = 0

const req = {
transactionID: 'transaction-id',
payload: {
find: async (args: { where: { id: { equals: string } } }) => {
const id = args.where.id.equals

calls.push(`start:${id}`)
activeFinds += 1
maxActiveFinds = Math.max(maxActiveFinds, activeFinds)

if (id === 'first') {
firstStarted.resolve()
await releaseFirst.promise
}

calls.push(`finish:${id}`)
activeFinds -= 1

return { docs: [] }
},
},
}

const dataLoader = getDataLoader(req as never)
const firstFind = dataLoader.find({
collection: 'items',
req,
where: { id: { equals: 'first' } },
} as never)

await firstStarted.promise

const secondFind = dataLoader.find({
collection: 'items',
req,
where: { id: { equals: 'second' } },
} as never)

await Promise.resolve()

expect(maxActiveFinds).toBe(1)

releaseFirst.resolve()
await Promise.all([firstFind, secondFind])

expect(calls).toEqual(['start:first', 'finish:first', 'start:second', 'finish:second'])
})

it('should not serialize find calls outside a transaction', async () => {
const calls: string[] = []
const firstStarted = createDeferred()
const releaseFirst = createDeferred()
let activeFinds = 0
let maxActiveFinds = 0

const req = {
payload: {
find: async (args: { where: { id: { equals: string } } }) => {
const id = args.where.id.equals

calls.push(`start:${id}`)
activeFinds += 1
maxActiveFinds = Math.max(maxActiveFinds, activeFinds)

if (id === 'first') {
firstStarted.resolve()
await releaseFirst.promise
}

calls.push(`finish:${id}`)
activeFinds -= 1

return { docs: [] }
},
},
}

const dataLoader = getDataLoader(req as never)
const firstFind = dataLoader.find({
collection: 'items',
req,
where: { id: { equals: 'first' } },
} as never)

await firstStarted.promise

const secondFind = dataLoader.find({
collection: 'items',
req,
where: { id: { equals: 'second' } },
} as never)

await secondFind

expect(maxActiveFinds).toBe(2)
expect(calls).toEqual(['start:first', 'start:second', 'finish:second'])

releaseFirst.resolve()
await firstFind

expect(calls).toEqual(['start:first', 'start:second', 'finish:second', 'finish:first'])
})
})
13 changes: 12 additions & 1 deletion packages/payload/src/collections/dataloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,14 +166,25 @@ const batchAndLoadDocs =
export const getDataLoader = (req: PayloadRequest) => {
const findQueries = new Map()
const dataLoader = new DataLoader(batchAndLoadDocs(req)) as PayloadRequest['payloadDataLoader']
let findQueue: Promise<unknown> = Promise.resolve()

dataLoader.find = ((args: FindArgs) => {
const key = createFindDataloaderCacheKey(args)
const cached = findQueries.get(key)
if (cached) {
return cached
}
const request = req.payload.find(args)

const hasTransaction = Boolean(args.req?.transactionID || req.transactionID)
// MongoDB sessions cannot run multiple operations concurrently in the same transaction.
const request = hasTransaction
? findQueue.then(() => req.payload.find(args))
: req.payload.find(args)

if (hasTransaction) {
findQueue = request.catch(() => undefined)
}

findQueries.set(key, request)
return request
}) as Payload['find']
Expand Down
54 changes: 53 additions & 1 deletion test/plugin-multi-tenant/int.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ import type { Relationship } from './payload-types.js'

import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js'
import { devUser } from '../credentials.js'
import { multiTenantPostsSlug, relationshipsSlug, tenantsSlug, usersSlug } from './shared.js'
import {
menuItemsSlug,
menuSlug,
multiTenantPostsSlug,
relationshipsSlug,
tenantsSlug,
usersSlug,
} from './shared.js'

let payload: Payload
let restClient: NextRESTClient
Expand Down Expand Up @@ -101,6 +108,51 @@ describe('@payloadcms/plugin-multi-tenant', () => {
expect(newRelationship.relationship?.title).toBe('Owned by bar with no ac')
})

it('should create an array of same-tenant relationships without transaction races', async () => {
const rowCount = 8
const tenant = await payload.create({
collection: tenantsSlug,
data: {
domain: 'relationship-race.test',
name: 'Relationship Race Tenant',
},
})
const menuItems = await Promise.all(
Array.from({ length: rowCount }, (_, index) =>
payload.create({
collection: menuItemsSlug,
data: {
name: `Relationship Race Item ${index}`,
tenant: tenant.id,
},
}),
),
)

const menu = await payload.create({
collection: menuSlug,
data: {
menuItems: menuItems.map((menuItem) => ({
active: true,
menuItem: menuItem.id,
})),
tenant: tenant.id,
title: 'Relationship Race Menu',
},
req: {
headers: new Headers([['cookie', `payload-tenant=${tenant.id}`]]),
},
})

expect(menu.menuItems).toHaveLength(rowCount)

await payload.delete({ collection: menuSlug, id: menu.id })
for (const menuItem of menuItems) {
await payload.delete({ collection: menuItemsSlug, id: menuItem.id })
}
await payload.delete({ collection: tenantsSlug, id: tenant.id })
})

it('ensure relationship document with relationship to different tenant cannot be created if tenant header passed', async () => {
await expect(
payload.create({
Expand Down
Loading