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:
co-authored by
Carlos R. L. Rodrigues
parent
5b135a41fe
commit
b9d6f73320
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user