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 16:19:06 +00:00
committed by GitHub
co-authored by Carlos R. L. Rodrigues
parent 5b135a41fe
commit b9d6f73320
117 changed files with 5741 additions and 530 deletions
+1
View File
@@ -18,3 +18,4 @@ export * as PromotionUtils from "./promotion"
export * as SearchUtils from "./search"
export * as ShippingProfileUtils from "./shipping"
export * as UserUtils from "./user"
export * as CachingUtils from "./caching"
+249
View File
@@ -0,0 +1,249 @@
import { ICachingModuleService, Logger, MedusaContainer } from "@medusajs/types"
import { MedusaContextType, Modules } from "../modules-sdk"
import { FeatureFlag } from "../feature-flags"
import { ContainerRegistrationKeys, isObject } from "../common"
/**
* This function is used to cache the result of a function call.
*
* @param cb - The callback to execute.
* @param options - The options for the cache.
* @returns The result of the callback.
*/
export async function useCache<T>(
cb: (...args: any[]) => Promise<T>,
options: {
enable?: boolean
key: string | any[]
tags?: string[]
ttl?: number
/**
* Whethere the default strategy should auto invalidate the cache whenever it is possible.
*/
autoInvalidate?: boolean
providers?: string[]
container: MedusaContainer
}
): Promise<T> {
const cachingModule = options.container.resolve<ICachingModuleService>(
Modules.CACHING,
{
allowUnregistered: true,
}
)
if (
!options.enable ||
!FeatureFlag.isFeatureEnabled("caching") ||
!cachingModule
) {
return await cb()
}
let key: string
if (typeof options.key === "string") {
key = options.key
} else {
key = await cachingModule.computeKey(options.key)
}
const data = await cachingModule.get({
key,
tags: options.tags,
providers: options.providers,
})
if (data) {
return data as T
}
const result = await cb()
void cachingModule
.set({
key,
tags: options.tags,
ttl: options.ttl,
data: result as object,
options: { autoInvalidate: options.autoInvalidate },
providers: options.providers,
})
.catch((e) => {
const logger =
options.container.resolve<Logger>(ContainerRegistrationKeys.LOGGER, {
allowUnregistered: true,
}) ?? (console as unknown as Logger)
logger.error(
`An error occured while setting cache for key: ${key}\n${e.message}\n${e.stack}`
)
})
return result
}
type TargetMethodArgs<Target, PropertyKey> = Target[PropertyKey &
keyof Target] extends (...args: any[]) => any
? Parameters<Target[PropertyKey & keyof Target]>
: never
/**
* This function is used to cache the result of a method call.
*
* @param options - The options for the cache.
* @returns The original method with the cache applied.
*/
export function Cached<
const Target extends object,
const PropertyKey extends keyof Target
>(options: {
/**
* The key to use for the cache.
* If a function is provided, it will be called with the arguments as the first argument and the
* container as the second argument.
*/
key?:
| string
| ((
args: TargetMethodArgs<Target, PropertyKey>,
cachingModule: ICachingModuleService
) => string | Promise<string> | Promise<any[]> | any[])
/**
* Whether to enable the cache. This is only useful if you want to enable without providing any
* other options.
*/
enable?:
| boolean
| ((args: TargetMethodArgs<Target, PropertyKey>) => boolean | undefined)
/**
* The tags to use for the cache.
*/
tags?:
| string[]
| ((args: TargetMethodArgs<Target, PropertyKey>) => string[] | undefined)
/**
* The time-to-live (TTL) value in seconds.
*/
ttl?:
| number
| ((args: TargetMethodArgs<Target, PropertyKey>) => number | undefined)
/**
* Whether to auto invalidate the cache whenever it is possible.
*/
autoInvalidate?:
| boolean
| ((args: TargetMethodArgs<Target, PropertyKey>) => boolean | undefined)
/**
* The providers to use for the cache.
*/
providers?:
| string[]
| ((args: TargetMethodArgs<Target, PropertyKey>) => string[] | undefined)
container: MedusaContainer | ((this: Target) => MedusaContainer)
}) {
return function (
target: Target,
propertyKey: PropertyKey,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value
if (typeof originalMethod !== "function") {
throw new Error("@cached can only be applied to methods")
}
descriptor.value = async function (
...args: Target[PropertyKey & keyof Target] extends (
...args: any[]
) => any
? Parameters<Target[PropertyKey & keyof Target]>
: never
) {
const container: MedusaContainer =
typeof options.container === "function"
? options.container.call(this)
: options.container
const cachingModule = container.resolve<ICachingModuleService>(
Modules.CACHING,
{
allowUnregistered: true,
}
)
if (!FeatureFlag.isFeatureEnabled("caching") || !cachingModule) {
return await originalMethod.apply(this, args)
}
if (!options.key) {
options.key = await cachingModule.computeKey(
args
.map((arg) => {
if (isObject(arg)) {
// Prevent any container, manager, transactionManager, etc from being included in the key
const {
container,
manager,
transactionManager,
__type,
...rest
} = arg as any
if (__type === MedusaContextType) {
return
}
return rest
}
return arg
})
.filter(Boolean)
)
}
const resolvableKeys = [
"enable",
"key",
"tags",
"ttl",
"autoInvalidate",
"providers",
]
const cacheOptions = {} as Parameters<typeof useCache>[1]
const promises: Promise<any>[] = []
for (const key of resolvableKeys) {
if (typeof options[key] === "function") {
const res = options[key](args, cachingModule)
if (res instanceof Promise) {
promises.push(
res.then((value) => {
cacheOptions[key] = value
})
)
} else {
cacheOptions[key] = res
}
} else {
cacheOptions[key] = options[key]
}
}
await Promise.all(promises)
if (!cacheOptions.enable) {
return await originalMethod.apply(this, args)
}
Object.assign(cacheOptions, {
container,
})
return await useCache(
() => originalMethod.apply(this, args),
cacheOptions as Parameters<typeof useCache>[1]
)
}
return descriptor
}
}
@@ -2,7 +2,22 @@ import { ContainerLike } from "@medusajs/types"
export function createContainerLike(obj): ContainerLike {
return {
resolve(key: string) {
resolve(
key: string,
{
allowUnregistered = false,
}: {
allowUnregistered?: boolean
} = {}
) {
if (allowUnregistered) {
try {
return obj[key]
} catch (error) {
return undefined
}
}
return obj[key]
},
}
+1 -1
View File
@@ -14,7 +14,7 @@ export class IdProperty extends BaseProperty<string> {
return !!value?.[IsIdProperty] || value?.dataType?.name === "id"
}
protected dataType: {
dataType: {
name: "id"
options: {
prefix?: string
@@ -24,6 +24,10 @@ export class PrimaryKeyModifier<T, Schema extends PropertyType<T>>
*/
#schema: Schema
get schema() {
return this.#schema
}
constructor(schema: Schema) {
this.#schema = schema
}
+50 -1
View File
@@ -1,4 +1,8 @@
import { EventBusTypes, InternalModuleDeclaration } from "@medusajs/types"
import {
EventBusTypes,
InterceptorSubscriber,
InternalModuleDeclaration,
} from "@medusajs/types"
import { ulid } from "ulid"
export abstract class AbstractEventBusModuleService
@@ -11,6 +15,8 @@ export abstract class AbstractEventBusModuleService
EventBusTypes.SubscriberDescriptor[]
> = new Map()
protected interceptorSubscribers_: Set<InterceptorSubscriber> = new Set()
public get eventToSubscribersMap(): Map<
string | symbol,
EventBusTypes.SubscriberDescriptor[]
@@ -134,6 +140,49 @@ export abstract class AbstractEventBusModuleService
return this
}
/**
* Add an interceptor subscriber that receives all messages before they are emitted
*
* @param interceptor - Function that receives messages before emission
* @returns this for chaining
*/
public addInterceptor(interceptor: InterceptorSubscriber): this {
this.interceptorSubscribers_.add(interceptor)
return this
}
/**
* Remove an interceptor subscriber
*
* @param interceptor - Function to remove from interceptors
* @returns this for chaining
*/
public removeInterceptor(interceptor: InterceptorSubscriber): this {
this.interceptorSubscribers_.delete(interceptor)
return this
}
/**
* Call all interceptor subscribers with the message before emission
* This should be called by implementations before emitting events
*
* @param message - The message to be intercepted
* @param context - Optional context about the emission
*/
protected async callInterceptors<T = unknown>(
message: EventBusTypes.Message<T>,
context?: { isGrouped?: boolean; eventGroupId?: string }
): Promise<void> {
Array.from(this.interceptorSubscribers_).map(async (interceptor) => {
try {
await interceptor(message, context)
} catch (error) {
// Log error but don't stop other interceptors or the emission
console.error("Error in event bus interceptor:", error)
}
})
}
}
export * from "./build-event-messages"
+1
View File
@@ -29,6 +29,7 @@ export * from "./shipping"
export * from "./totals"
export * from "./totals/big-number"
export * from "./user"
export * from "./caching"
export const MedusaModuleType = Symbol.for("MedusaModule")
export const MedusaModuleProviderType = Symbol.for("MedusaModuleProvider")
@@ -48,6 +48,7 @@ describe("joiner-config-builder", () => {
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: "",
idPrefixToEntityName: {},
linkableKeys: {
fulfillment_set_id: FulfillmentSet.name,
shipping_option_id: ShippingOption.name,
@@ -136,6 +137,7 @@ describe("joiner-config-builder", () => {
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: "",
idPrefixToEntityName: {},
linkableKeys: {},
alias: [
{
@@ -176,6 +178,7 @@ describe("joiner-config-builder", () => {
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: "",
idPrefixToEntityName: {},
linkableKeys: {
fulfillment_set_id: FulfillmentSet.name,
shipping_option_id: ShippingOption.name,
@@ -269,6 +272,7 @@ describe("joiner-config-builder", () => {
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: "",
idPrefixToEntityName: {},
linkableKeys: {},
alias: [
{
@@ -300,6 +304,7 @@ describe("joiner-config-builder", () => {
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: "",
idPrefixToEntityName: {},
linkableKeys: {
fulfillment_set_id: FulfillmentSet.name,
},
@@ -335,6 +340,7 @@ describe("joiner-config-builder", () => {
serviceName: Modules.FULFILLMENT,
primaryKeys: ["id"],
schema: expect.any(String),
idPrefixToEntityName: {},
linkableKeys: {
fulfillment_set_id: FulfillmentSet.name,
shipping_option_id: ShippingOption.name,
@@ -27,6 +27,7 @@ export const Modules = {
INDEX: "index",
LOCKING: "locking",
SETTINGS: "settings",
CACHING: "caching",
} as const
export const MODULE_PACKAGE_NAMES = {
@@ -58,6 +59,7 @@ export const MODULE_PACKAGE_NAMES = {
[Modules.INDEX]: "@medusajs/medusa/index-module",
[Modules.LOCKING]: "@medusajs/medusa/locking",
[Modules.SETTINGS]: "@medusajs/medusa/settings",
[Modules.CACHING]: "@medusajs/caching",
}
export const REVERSED_MODULE_PACKAGE_NAMES = Object.entries(
@@ -46,8 +46,10 @@ export function defineJoinerConfig(
models,
linkableKeys,
primaryKeys,
idPrefixToEntityName,
}: {
alias?: JoinerServiceConfigAlias[]
idPrefixToEntityName?: Record<string, string>
schema?: string
models?: DmlEntity<any, any>[] | { name: string }[]
linkableKeys?: ModuleJoinerConfig["linkableKeys"]
@@ -150,6 +152,12 @@ export function defineJoinerConfig(
schema = toGraphQLSchema([...modelDefinitions.values()])
}
if (!idPrefixToEntityName) {
idPrefixToEntityName = buildIdPrefixToEntityNameFromDmlObjects([
...modelDefinitions.values(),
])
}
const linkableKeysFromDml = buildLinkableKeysFromDmlObjects([
...modelDefinitions.values(),
])
@@ -199,6 +207,7 @@ export function defineJoinerConfig(
serviceName,
primaryKeys,
schema,
idPrefixToEntityName,
linkableKeys: linkableKeys,
alias: [
...[...(alias ?? ([] as any))].map((alias) => ({
@@ -230,6 +239,31 @@ export function defineJoinerConfig(
}
}
/**
* Build the id prefix to entity name map from the DML objects
* @param models
*/
export function buildIdPrefixToEntityNameFromDmlObjects(
models: DmlEntity<any, any>[]
): Record<string, string> {
return models.reduce((acc, model) => {
const id = model.parse().schema.id as
| IdProperty
| PrimaryKeyModifier<any, any>
if (
PrimaryKeyModifier.isPrimaryKeyModifier(id) &&
id.schema.dataType.options.prefix
) {
acc[id.schema.dataType.options.prefix] = model.name
} else if (IdProperty.isIdProperty(id) && id.dataType.options.prefix) {
acc[id.dataType.options.prefix] = model.name
}
return acc
}, {})
}
/**
* From a set of DML objects, build the linkable keys
*
@@ -1,6 +1,7 @@
import { Constructor, IDmlEntity, ModuleExports } from "@medusajs/types"
import { DmlEntity } from "../dml"
import {
buildIdPrefixToEntityNameFromDmlObjects,
buildLinkConfigFromLinkableKeys,
buildLinkConfigFromModelObjects,
defineJoinerConfig,
@@ -53,6 +54,10 @@ export function Module<
// TODO: Add support for non linkable modifier DML object to be skipped from the linkable generation
const linkableKeys = service.prototype.__joinerConfig().linkableKeys
service.prototype.__joinerConfig().idPrefixToEntityName =
buildIdPrefixToEntityNameFromDmlObjects(
dmlObjects.map(([, model]) => model) as DmlEntity<any, any>[]
)
if (dmlObjects.length) {
linkable = buildLinkConfigFromModelObjects<ServiceName, ModelObjects>(
@@ -155,18 +155,25 @@ const getDataForComputation = async (
query: Omit<RemoteQueryFunction, symbol>,
data: { variant_ids: string[]; sales_channel_id?: string }
) => {
const { data: variantInventoryItems } = await query.graph({
entity: "product_variant_inventory_items",
fields: [
"variant_id",
"required_quantity",
"variant.manage_inventory",
"variant.allow_backorder",
"inventory.*",
"inventory.location_levels.*",
],
filters: { variant_id: data.variant_ids },
})
const { data: variantInventoryItems } = await query.graph(
{
entity: "product_variant_inventory_items",
fields: [
"variant_id",
"required_quantity",
"variant.manage_inventory",
"variant.allow_backorder",
"inventory.*",
"inventory.location_levels.*",
],
filters: { variant_id: data.variant_ids },
},
{
cache: {
enable: true,
},
}
)
const variantInventoriesMap = new Map()
variantInventoryItems.forEach((link) => {
@@ -177,11 +184,21 @@ const getDataForComputation = async (
const locationIds = new Set<string>()
if (data.sales_channel_id) {
const { data: channelLocations } = await query.graph({
entity: "sales_channel_locations",
fields: ["stock_location_id"],
filters: { sales_channel_id: data.sales_channel_id },
})
const { data: channelLocations } = await query.graph(
{
entity: "sales_channel_locations",
fields: ["stock_location_id"],
filters: { sales_channel_id: data.sales_channel_id },
},
{
cache: {
tags: [
`SalesChannel:${data.sales_channel_id}`,
"StockLocation:list:*",
],
},
}
)
channelLocations.forEach((loc) => locationIds.add(loc.stock_location_id))
}