Compare commits

...

1 Commits

Author SHA1 Message Date
Sasha
0a5c97b568 fix: invalidate cache in dataloader after a document was updated or deleted 2025-03-06 20:45:46 +02:00
5 changed files with 133 additions and 4 deletions

View File

@@ -3,11 +3,18 @@ import type { BatchLoadFn } from 'dataloader'
import DataLoader from 'dataloader'
import type { CollectionSlug } from '../index.js'
import type { PayloadRequest, PopulateType, SelectType } from '../types/index.js'
import type { TypeWithID } from './config/types.js'
import { isValidID } from '../utilities/isValidID.js'
const InternalIDSlugCacheKeyMap = Symbol('InternalIDSlugCacheKeyMap')
const getInternalIDSlugCacheKeyMap = (req: PayloadRequest): Map<string, string[]> => {
return req.payloadDataLoader[InternalIDSlugCacheKeyMap]
}
// Payload uses `dataloader` to solve the classic GraphQL N+1 problem.
// We keep a list of all documents requested to be populated for any given request
@@ -145,6 +152,14 @@ const batchAndLoadDocs =
})
const docsIndex = keys.findIndex((key) => key === docKey)
const map = getInternalIDSlugCacheKeyMap(req)
let items = map.get(`${doc.id}-${collection}`)
if (!items) {
items = []
map.set(`${doc.id}-${collection}`, items)
}
items.push(docKey)
if (docsIndex > -1) {
docs[docsIndex] = doc
}
@@ -157,7 +172,12 @@ const batchAndLoadDocs =
return docs
}
export const getDataLoader = (req: PayloadRequest) => new DataLoader(batchAndLoadDocs(req))
export const getDataLoader = (req: PayloadRequest): DataLoader<string, TypeWithID> => {
const dataLoader = new DataLoader(batchAndLoadDocs(req))
dataLoader[InternalIDSlugCacheKeyMap] = new Map()
return dataLoader
}
type CreateCacheKeyArgs = {
collectionSlug: string
@@ -201,3 +221,30 @@ export const createDataloaderCacheKey = ({
select,
populate,
])
export const invalidateDocumentInDataloader = ({
id,
collectionSlug,
req,
}: {
collectionSlug: CollectionSlug
id: number | string
req: PayloadRequest
}) => {
if (!req.payloadDataLoader) {
return
}
const cacheKeyMap = getInternalIDSlugCacheKeyMap(req)
const keys = cacheKeyMap.get(`${id}-${collectionSlug}`)
if (!keys) {
return
}
for (const key of keys) {
req.payloadDataLoader.clear(key)
}
cacheKeyMap.delete(`${id}-${collectionSlug}`)
}

View File

@@ -25,6 +25,7 @@ import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { deleteCollectionVersions } from '../../versions/deleteCollectionVersions.js'
import { deleteScheduledPublishJobs } from '../../versions/deleteScheduledPublishJobs.js'
import { invalidateDocumentInDataloader } from '../dataloader.js'
import { buildAfterOperation } from './utils.js'
export type Arguments = {
@@ -203,6 +204,8 @@ export const deleteOperation = async <
},
})
invalidateDocumentInDataloader({ id, collectionSlug: collectionConfig.slug, req })
// /////////////////////////////////////
// afterRead - Fields
// /////////////////////////////////////

View File

@@ -6,7 +6,7 @@ import type {
SelectType,
TransformCollectionWithSelect,
} from '../../types/index.js'
import type { BeforeOperationHook, Collection, DataFromCollectionSlug } from '../config/types.js'
import type { Collection, DataFromCollectionSlug } from '../config/types.js'
import executeAccess from '../../auth/executeAccess.js'
import { hasWhereAccessResult } from '../../auth/types.js'
@@ -21,6 +21,7 @@ import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { deleteCollectionVersions } from '../../versions/deleteCollectionVersions.js'
import { deleteScheduledPublishJobs } from '../../versions/deleteScheduledPublishJobs.js'
import { invalidateDocumentInDataloader } from '../dataloader.js'
import { buildAfterOperation } from './utils.js'
export type Arguments = {
@@ -177,6 +178,8 @@ export const deleteByIDOperation = async <TSlug extends CollectionSlug, TSelect
where: { id: { equals: id } },
})
invalidateDocumentInDataloader({ id, collectionSlug: collectionConfig.slug, req })
// /////////////////////////////////////
// Delete Preferences
// /////////////////////////////////////

View File

@@ -34,6 +34,7 @@ import { uploadFiles } from '../../../uploads/uploadFiles.js'
import { checkDocumentLockStatus } from '../../../utilities/checkDocumentLockStatus.js'
import { getLatestCollectionVersion } from '../../../versions/getLatestCollectionVersion.js'
import { saveVersion } from '../../../versions/saveVersion.js'
import { invalidateDocumentInDataloader } from '../../dataloader.js'
export type SharedUpdateDocumentArgs<TSlug extends CollectionSlug> = {
accessResults: AccessResult
@@ -314,6 +315,8 @@ export const updateDocument = async <
})
}
invalidateDocumentInDataloader({ id, collectionSlug: collectionConfig.slug, req })
// /////////////////////////////////////
// afterRead - Fields
// /////////////////////////////////////

View File

@@ -1,11 +1,17 @@
import type { Payload, PayloadRequest } from 'payload'
import { randomBytes, randomUUID } from 'crypto'
import path from 'path'
import {
commitTransaction,
createLocalReq,
initTransaction,
type Payload,
type PayloadRequest,
} from 'payload'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import type {
Chained,
ChainedRelation,
CustomIdNumberRelation,
CustomIdRelation,
@@ -898,6 +904,73 @@ describe('Relationships', () => {
expect(doc?.chainedRelation).toEqual(chained.id)
})
it('should populate with a right data within the same transaction after update', async () => {
const req = await createLocalReq({}, payload)
await initTransaction(req)
const chained = await payload.create({
collection: chainedRelSlug,
req,
data: { name: 'my-name' },
})
let doc = await payload.create({
collection: slug,
data: { chainedRelation: chained.id },
req,
})
expect((doc.chainedRelation as Chained).id).toBe(chained.id)
await payload.update({
collection: chainedRelSlug,
req,
data: { name: 'new-name' },
id: chained.id,
})
doc = await payload.findByID({
collection: slug,
id: doc.id,
req,
})
expect((doc.chainedRelation as Chained).name).toBe('new-name')
await commitTransaction(req)
})
it('should not populate within the same transaction after delete', async () => {
const req = await createLocalReq({}, payload)
await initTransaction(req)
const chained = await payload.create({
collection: chainedRelSlug,
req,
data: { name: 'my-name' },
})
let doc = await payload.create({
collection: slug,
data: { chainedRelation: chained.id },
req,
})
expect((doc.chainedRelation as Chained).id).toBe(chained.id)
await payload.delete({
collection: chainedRelSlug,
req,
id: chained.id,
})
doc = await payload.findByID({
collection: slug,
id: doc.id,
req,
})
expect((doc.chainedRelation as Chained)?.name).toBeUndefined()
await commitTransaction(req)
})
it('should respect maxDepth at field level', async () => {
const doc = await restClient
.GET(`/${slug}/${post.id}`, {