Feat(): distributed caching (#13435)

RESOLVES CORE-1153

**What**
- This pr mainly lay the foundation the caching layer. It comes with a modules (built in memory cache) and a redis provider.
- Apply caching to few touch point to test

Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2025-09-30 18:19:06 +02:00
committed by GitHub
parent 5b135a41fe
commit b9d6f73320
117 changed files with 5741 additions and 530 deletions

View File

@@ -0,0 +1,487 @@
import { GraphQLSchema, buildSchema } from "graphql"
import { CacheInvalidationParser, EntityReference } from "../parser"
describe("CacheInvalidationParser", () => {
let parser: CacheInvalidationParser
let schema: GraphQLSchema
beforeEach(() => {
const schemaDefinition = `
type Product {
id: ID!
title: String
description: String
collection: ProductCollection
categories: [ProductCategory!]
variants: [ProductVariant!]
created_at: String
updated_at: String
}
type ProductCollection {
id: ID!
title: String
products: [Product!]
created_at: String
updated_at: String
}
type ProductCategory {
id: ID!
name: String
products: [Product!]
parent: ProductCategory
children: [ProductCategory!]
created_at: String
updated_at: String
}
type ProductVariant {
id: ID!
title: String
sku: String
product: Product!
prices: [Price!]
created_at: String
updated_at: String
}
type Price {
id: ID!
amount: Int
currency_code: String
variant: ProductVariant!
created_at: String
updated_at: String
}
type Order {
id: ID!
status: String
items: [OrderItem!]
customer: Customer
created_at: String
updated_at: String
}
type OrderItem {
id: ID!
quantity: Int
order: Order!
variant: ProductVariant!
created_at: String
updated_at: String
}
type Customer {
id: ID!
first_name: String
last_name: String
email: String
orders: [Order!]
created_at: String
updated_at: String
}
`
schema = buildSchema(schemaDefinition)
parser = new CacheInvalidationParser(schema, [
// Partially populate this record ro force the test to match from both id prefix or type
// detection
{
idPrefixToEntityName: {
prod: "Product",
col: "ProductCollection",
cat: "ProductCategory",
},
},
])
})
describe("parseObjectForEntities", () => {
it("should identify a simple product entity", () => {
const product = {
id: "prod_123",
title: "Test Product",
description: "A test product",
}
const entities = parser.parseObjectForEntities(product)
expect(entities).toHaveLength(1)
expect(entities[0]).toEqual({
type: "Product",
id: "prod_123",
isInArray: false,
})
})
it("should identify nested entities in a product with collection", () => {
const product = {
id: "prod_123",
title: "Test Product",
collection: {
id: "col_456",
title: "Test Collection",
},
}
const entities = parser.parseObjectForEntities(product)
expect(entities).toHaveLength(2)
expect(entities).toContainEqual({
type: "Product",
id: "prod_123",
isInArray: false,
})
expect(entities).toContainEqual({
type: "ProductCollection",
id: "col_456",
isInArray: false,
})
})
it("should identify entities in arrays", () => {
const product = {
id: "prod_123",
title: "Test Product",
variants: [
{
id: "var_789",
title: "Variant 1",
sku: "SKU-001",
},
{
id: "var_790",
title: "Variant 2",
sku: "SKU-002",
},
],
}
const entities = parser.parseObjectForEntities(product)
expect(entities).toHaveLength(3)
expect(entities).toContainEqual({
type: "Product",
id: "prod_123",
isInArray: false,
})
expect(entities).toContainEqual({
type: "ProductVariant",
id: "var_789",
isInArray: true,
})
expect(entities).toContainEqual({
type: "ProductVariant",
id: "var_790",
isInArray: true,
})
})
it("should handle deeply nested entities", () => {
const order = {
id: "order_123",
status: "completed",
items: [
{
id: "item_456",
quantity: 2,
variant: {
id: "var_789",
title: "Variant 1",
product: {
id: "prod_123",
title: "Test Product",
collection: {
id: "col_456",
title: "Test Collection",
},
},
},
},
],
customer: {
id: "cus_789",
email: "test@example.com",
first_name: "John",
},
}
const entities = parser.parseObjectForEntities(order)
expect(entities).toHaveLength(6)
expect(entities).toContainEqual({
type: "Order",
id: "order_123",
isInArray: false,
})
expect(entities).toContainEqual({
type: "OrderItem",
id: "item_456",
isInArray: true,
})
expect(entities).toContainEqual({
type: "ProductVariant",
id: "var_789",
isInArray: false,
})
expect(entities).toContainEqual({
type: "Product",
id: "prod_123",
isInArray: false,
})
expect(entities).toContainEqual({
type: "ProductCollection",
id: "col_456",
isInArray: false,
})
expect(entities).toContainEqual({
type: "Customer",
id: "cus_789",
isInArray: false,
})
})
it("should return empty array for null or primitive values", () => {
expect(parser.parseObjectForEntities(null)).toEqual([])
expect(parser.parseObjectForEntities(undefined)).toEqual([])
expect(parser.parseObjectForEntities("string")).toEqual([])
expect(parser.parseObjectForEntities(123)).toEqual([])
expect(parser.parseObjectForEntities(true)).toEqual([])
})
it("should ignore objects without id field", () => {
const invalidObject = {
title: "No ID Object",
description: "This object has no ID",
}
const entities = parser.parseObjectForEntities(invalidObject)
expect(entities).toEqual([])
})
it("should handle objects with partial field matches", () => {
const partialProduct = {
id: "prod_123",
title: "Test Product",
unknown_field: "Should still work",
}
const entities = parser.parseObjectForEntities(partialProduct)
expect(entities).toHaveLength(1)
expect(entities[0]).toEqual({
type: "Product",
id: "prod_123",
isInArray: false,
})
})
})
describe("buildInvalidationEvents", () => {
it("should build invalidation events for a single entity", () => {
const entities: EntityReference[] = [{ type: "Product", id: "prod_123" }]
const events = parser.buildInvalidationEvents(entities)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
entityType: "Product",
entityId: "prod_123",
relatedEntities: [],
})
expect(events[0].cacheKeys).toEqual(["Product:prod_123"])
})
it("should build invalidation events with related entities", () => {
const entities: EntityReference[] = [
{ type: "Product", id: "prod_123" },
{ type: "ProductCollection", id: "col_456" },
{ type: "ProductVariant", id: "var_789" },
]
const events = parser.buildInvalidationEvents(entities)
expect(events).toHaveLength(3)
const productEvent = events.find((e) => e.entityType === "Product")
expect(productEvent).toBeDefined()
expect(productEvent!.relatedEntities).toHaveLength(2)
expect(productEvent!.cacheKeys).toEqual(["Product:prod_123"])
})
it("should avoid duplicate entities in events", () => {
const entities: EntityReference[] = [
{ type: "Product", id: "prod_123" },
{ type: "Product", id: "prod_123" }, // Duplicate
{ type: "ProductCollection", id: "col_456" },
]
const events = parser.buildInvalidationEvents(entities)
expect(events).toHaveLength(2) // Should only have Product and ProductCollection events
expect(events.map((e) => e.entityType).sort()).toEqual([
"Product",
"ProductCollection",
])
})
it("should generate comprehensive cache keys", () => {
const entities: EntityReference[] = [
{ type: "Product", id: "prod_123" },
{ type: "ProductCollection", id: "col_456" },
]
const events = parser.buildInvalidationEvents(entities)
const productEvent = events.find((e) => e.entityType === "Product")!
expect(productEvent.cacheKeys).toEqual(["Product:prod_123"])
})
})
describe("integration scenarios", () => {
it("should handle a complete product updated scenario", () => {
const productData = {
id: "prod_123",
title: "Updated Product Title",
collection: {
id: "col_456",
title: "Fashion Collection",
},
categories: [
{ id: "cat_789", name: "Shirts" },
{ id: "cat_790", name: "Casual" },
],
variants: [
{
id: "var_111",
title: "Size S",
prices: [{ id: "price_222", amount: 2999, currency_code: "USD" }],
},
],
}
const entities = parser.parseObjectForEntities(productData)
const events = parser.buildInvalidationEvents(entities)
// Should identify all nested entities
expect(entities).toHaveLength(6) // Product, Collection, 2 Categories, Variant, Price
// Should created events for each entity type
expect(events).toHaveLength(6)
// Validate cache keys for each entity type
const productEvent = events.find((e) => e.entityType === "Product")!
expect(productEvent.cacheKeys).toEqual(["Product:prod_123"])
const collectionEvent = events.find(
(e) => e.entityType === "ProductCollection"
)!
expect(collectionEvent.cacheKeys).toEqual(["ProductCollection:col_456"])
const categoryEvents = events.filter(
(e) => e.entityType === "ProductCategory"
)
expect(categoryEvents).toHaveLength(2)
expect(categoryEvents[0].cacheKeys).toEqual([
"ProductCategory:cat_789",
"ProductCategory:list:*",
])
expect(categoryEvents[1].cacheKeys).toEqual([
"ProductCategory:cat_790",
"ProductCategory:list:*",
])
const variantEvent = events.find(
(e) => e.entityType === "ProductVariant"
)!
expect(variantEvent.cacheKeys).toEqual([
"ProductVariant:var_111",
"ProductVariant:list:*",
])
const priceEvent = events.find((e) => e.entityType === "Price")!
expect(priceEvent.cacheKeys).toEqual(["Price:price_222", "Price:list:*"])
})
it("should handle order with customer and items scenario", () => {
const orderData = {
id: "order_123",
status: "completed",
customer: {
id: "cus_456",
email: "customer@example.com",
},
items: [
{
id: "item_789",
quantity: 2,
variant: {
id: "var_111",
sku: "SHIRT-S-BLUE",
},
},
],
}
const entities = parser.parseObjectForEntities(orderData)
const events = parser.buildInvalidationEvents(entities)
expect(entities).toHaveLength(4) // Order, Customer, OrderItem, ProductVariant
expect(events).toHaveLength(4)
// Validate cache keys for each entity type
const orderEvent = events.find((e) => e.entityType === "Order")!
expect(orderEvent.cacheKeys).toEqual(["Order:order_123"])
const customerEvent = events.find((e) => e.entityType === "Customer")!
expect(customerEvent.cacheKeys).toEqual(["Customer:cus_456"])
const itemEvent = events.find((e) => e.entityType === "OrderItem")!
expect(itemEvent.cacheKeys).toEqual([
"OrderItem:item_789",
"OrderItem:list:*",
])
const variantEvent = events.find(
(e) => e.entityType === "ProductVariant"
)!
expect(variantEvent.cacheKeys).toEqual(["ProductVariant:var_111"])
})
it("should include simplified cache keys for created operation", () => {
const entities: EntityReference[] = [{ type: "Product", id: "prod_123" }]
const events = parser.buildInvalidationEvents(entities, "created")
const productEvent = events[0]
expect(productEvent.cacheKeys).toEqual([
"Product:prod_123",
"Product:list:*",
])
})
it("should include simplified cache keys for deleted operation", () => {
const entities: EntityReference[] = [{ type: "Product", id: "prod_123" }]
const events = parser.buildInvalidationEvents(entities, "deleted")
const productEvent = events[0]
expect(productEvent.cacheKeys).toEqual([
"Product:prod_123",
"Product:list:*",
])
})
it("should include simplified cache keys for updated operation", () => {
const entities: EntityReference[] = [{ type: "Product", id: "prod_123" }]
const events = parser.buildInvalidationEvents(entities, "updated")
const productEvent = events[0]
expect(productEvent.cacheKeys).toEqual(["Product:prod_123"])
})
})
})

View File

@@ -0,0 +1,242 @@
import { ModuleJoinerConfig } from "@medusajs/framework/types"
import { isObject } from "@medusajs/framework/utils"
import {
GraphQLObjectType,
GraphQLSchema,
isListType,
isNonNullType,
isObjectType,
} from "graphql"
export interface EntityReference {
type: string
id: string | number
field?: string
isInArray?: boolean
}
export interface InvalidationEvent {
entityType: string
entityId: string | number
relatedEntities: EntityReference[]
cacheKeys: string[]
}
export class CacheInvalidationParser {
private typeMap: Map<string, GraphQLObjectType>
private idPrefixToEntityName: Record<string, string>
constructor(schema: GraphQLSchema, joinerConfigs: ModuleJoinerConfig[]) {
this.typeMap = new Map()
// Build type map for quick lookups
const schemaTypeMap = schema.getTypeMap()
Object.keys(schemaTypeMap).forEach((typeName) => {
const type = schemaTypeMap[typeName]
if (isObjectType(type) && !typeName.startsWith("__")) {
this.typeMap.set(typeName, type)
}
})
this.idPrefixToEntityName = joinerConfigs.reduce((acc, joinerConfig) => {
if (joinerConfig.idPrefixToEntityName) {
Object.entries(joinerConfig.idPrefixToEntityName).forEach(
([idPrefix, entityName]) => {
acc[idPrefix] = entityName
}
)
}
return acc
}, {} as Record<string, string>)
}
/**
* Parse an object to identify entities and their relationships
*/
parseObjectForEntities(
obj: any,
parentType?: string,
isInArray: boolean = false
): EntityReference[] {
const entities: EntityReference[] = []
if (!obj || typeof obj !== "object") {
return entities
}
// Check if this object matches any known GraphQL types
const detectedType = this.detectEntityType(obj, parentType)
if (detectedType && obj.id) {
entities.push({
type: detectedType,
id: obj.id,
isInArray,
})
}
// Recursively parse nested objects and arrays
Object.keys(obj).forEach((key) => {
const value = obj[key]
if (Array.isArray(value)) {
value.forEach((item) => {
entities.push(
...this.parseObjectForEntities(
item,
this.getRelationshipType(detectedType, key),
true
)
)
})
} else if (isObject(value)) {
entities.push(
...this.parseObjectForEntities(
value,
this.getRelationshipType(detectedType, key),
false
)
)
}
})
return entities
}
/**
* Detect entity type based on object structure and GraphQL type map
*/
private detectEntityType(obj: any, suggestedType?: string): string | null {
if (obj.id) {
const idParts = obj.id.split("_")
if (idParts.length > 1 && this.idPrefixToEntityName[idParts[0]]) {
return this.idPrefixToEntityName[idParts[0]]
}
}
if (suggestedType && this.typeMap.has(suggestedType)) {
const type = this.typeMap.get(suggestedType)!
if (this.objectMatchesType(obj, type)) {
return suggestedType
}
}
// Try to match against all known types
for (const [typeName, type] of this.typeMap) {
if (this.objectMatchesType(obj, type)) {
return typeName
}
}
return null
}
/**
* Check if object structure matches GraphQL type fields
*/
private objectMatchesType(obj: any, type: GraphQLObjectType): boolean {
const fields = type.getFields()
const objKeys = Object.keys(obj)
// Must have id field for entities
if (!obj.id || !fields.id) {
return false
}
// Check if at least 50% of non-null object fields match type fields
const matchingFields = objKeys.filter((key) => fields[key]).length
return matchingFields >= Math.max(1, objKeys.length * 0.5)
}
/**
* Get the expected type for a relationship field
*/
private getRelationshipType(
parentType: string | null,
fieldName: string
): string | undefined {
if (!parentType || !this.typeMap.has(parentType)) {
return undefined
}
const type = this.typeMap.get(parentType)!
const field = type.getFields()[fieldName]
if (!field) {
return undefined
}
let fieldType = field.type
// Unwrap NonNull and List wrappers
if (isNonNullType(fieldType)) {
fieldType = fieldType.ofType
}
if (isListType(fieldType)) {
fieldType = fieldType.ofType
}
if (isNonNullType(fieldType)) {
fieldType = fieldType.ofType
}
if (isObjectType(fieldType)) {
return fieldType.name
}
return undefined
}
/**
* Build invalidation events based on parsed entities
*/
buildInvalidationEvents(
entities: EntityReference[],
operation: "created" | "updated" | "deleted" = "updated"
): InvalidationEvent[] {
const events: InvalidationEvent[] = []
const processedEntities = new Set<string>()
entities.forEach((entity) => {
const entityKey = `${entity.type}:${entity.id}`
if (processedEntities.has(entityKey)) {
return
}
processedEntities.add(entityKey)
const relatedEntities = entities.filter(
(e) => e.type !== entity.type || e.id !== entity.id
)
const affectedKeys = this.buildAffectedCacheKeys(entity, operation)
events.push({
entityType: entity.type,
entityId: entity.id,
relatedEntities,
cacheKeys: affectedKeys,
})
})
return events
}
/**
* Build list of cache keys that should be invalidated
*/
private buildAffectedCacheKeys(
entity: EntityReference,
operation: "created" | "updated" | "deleted" = "updated"
): string[] {
const keys = new Set<string>()
keys.add(`${entity.type}:${entity.id}`)
// Add list key only if entity was found in an array context or if an event of type created or
// deleted is triggered
if (entity.isInArray || ["created", "deleted"].includes(operation)) {
keys.add(`${entity.type}:list:*`)
}
return Array.from(keys)
}
}

View File

@@ -0,0 +1,133 @@
import type {
Event,
ICachingModuleService,
ICachingStrategy,
ModuleJoinerConfig,
} from "@medusajs/framework/types"
import {
type GraphQLSchema,
Modules,
toCamelCase,
upperCaseFirst,
} from "@medusajs/framework/utils"
import { type CachingModuleService } from "@services"
import type { InjectedDependencies } from "@types"
import stringify from "fast-json-stable-stringify"
import { CacheInvalidationParser, EntityReference } from "./parser"
export class DefaultCacheStrategy implements ICachingStrategy {
#cacheInvalidationParser: CacheInvalidationParser
#cacheModule: ICachingModuleService
#container: InjectedDependencies
#hasher: (data: string) => string
constructor(
container: InjectedDependencies,
cacheModule: CachingModuleService
) {
this.#cacheModule = cacheModule
this.#container = container
this.#hasher = container.hasher
}
objectHash(input: any): string {
const str = stringify(input)
return this.#hasher(str)
}
async onApplicationStart(
schema: GraphQLSchema,
joinerConfigs: ModuleJoinerConfig[]
) {
this.#cacheInvalidationParser = new CacheInvalidationParser(
schema,
joinerConfigs
)
const eventBus = this.#container[Modules.EVENT_BUS]
const handleEvent = async (data: Event) => {
try {
// We dont have to await anything here and the rest can be done in the background
return
} finally {
const eventName = data.name
const operation = eventName.split(".").pop() as
| "created"
| "updated"
| "deleted"
const entityType = eventName.split(".").slice(-2).shift()!
const eventData = data.data as
| { id: string | string[] }
| { id: string | string[] }[]
const normalizedEventData = Array.isArray(eventData)
? eventData
: [eventData]
const tags: string[] = []
for (const item of normalizedEventData) {
const ids = Array.isArray(item.id) ? item.id : [item.id]
for (const id of ids) {
const entityReference: EntityReference = {
type: upperCaseFirst(toCamelCase(entityType)),
id,
}
const tags_ = await this.computeTags(item, {
entities: [entityReference],
operation,
})
tags.push(...tags_)
}
}
void this.#cacheModule.clear({
tags,
options: { autoInvalidate: true },
})
}
}
eventBus.subscribe("*", handleEvent)
eventBus.addInterceptor?.(handleEvent)
}
async computeKey(input: object) {
return this.objectHash(input)
}
async computeTags(
input: object,
options?: {
entities?: EntityReference[]
operation?: "created" | "updated" | "deleted"
}
): Promise<string[]> {
// Parse the input object to identify entities
const entities_ =
options?.entities ||
this.#cacheInvalidationParser.parseObjectForEntities(input)
if (entities_.length === 0) {
return []
}
// Build invalidation events to get comprehensive cache keys
const events = this.#cacheInvalidationParser.buildInvalidationEvents(
entities_,
options?.operation
)
// Collect all unique cache keys from all events as tags
const tags = new Set<string>()
events.forEach((event) => {
event.cacheKeys.forEach((key) => tags.add(key))
})
return Array.from(tags)
}
}