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
@@ -0,0 +1,406 @@
import { MedusaModule } from "@medusajs/framework/modules-sdk"
import type {
ICachingModuleService,
ICachingStrategy,
Logger,
} from "@medusajs/framework/types"
import { GraphQLUtils, MedusaError } from "@medusajs/framework/utils"
import { CachingDefaultProvider, InjectedDependencies } from "@types"
import CacheProviderService from "./cache-provider"
const ONE_HOUR_IN_SECOND = 60 * 60
export default class CachingModuleService implements ICachingModuleService {
protected container: InjectedDependencies
protected providerService: CacheProviderService
protected strategyCtr: new (...args: any[]) => ICachingStrategy
protected strategy: ICachingStrategy
protected defaultProviderId: string
protected logger: Logger
protected ongoingRequests: Map<string, Promise<any>> = new Map()
protected ttl: number
static traceGet?: (
cacheGetFn: () => Promise<any>,
key: string,
tags: string[]
) => Promise<any>
static traceSet?: (
cacheSetFn: () => Promise<any>,
key: string,
tags: string[],
options: { autoInvalidate?: boolean }
) => Promise<any>
static traceClear?: (
cacheClearFn: () => Promise<any>,
key: string,
tags: string[],
options: { autoInvalidate?: boolean }
) => Promise<any>
constructor(
container: InjectedDependencies,
protected readonly moduleDeclaration:
| { options: { ttl?: number } }
| { ttl?: number }
) {
this.container = container
this.providerService = container.cacheProviderService
this.defaultProviderId = container[CachingDefaultProvider]
this.strategyCtr = container.strategy as new (
...args: any[]
) => ICachingStrategy
this.strategy = new this.strategyCtr(this.container, this)
const moduleOptions =
"options" in moduleDeclaration
? moduleDeclaration.options
: moduleDeclaration
this.ttl = moduleOptions.ttl ?? ONE_HOUR_IN_SECOND
this.logger = container.logger ?? (console as unknown as Logger)
}
__hooks = {
onApplicationStart: async () => {
this.onApplicationStart()
},
onApplicationShutdown: async () => {
this.onApplicationShutdown()
},
onApplicationPrepareShutdown: async () => {
this.onApplicationPrepareShutdown()
},
}
protected onApplicationStart() {
const loadedSchema = MedusaModule.getAllJoinerConfigs()
.map((joinerConfig) => joinerConfig?.schema ?? "")
.join("\n")
const defaultMedusaSchema = `
scalar DateTime
scalar JSON
directive @enumValue(value: String) on ENUM_VALUE
`
const { schema: cleanedSchema } = GraphQLUtils.cleanGraphQLSchema(
defaultMedusaSchema + loadedSchema
)
const mergedSchema = GraphQLUtils.mergeTypeDefs(cleanedSchema)
const schema = GraphQLUtils.makeExecutableSchema({
typeDefs: mergedSchema,
})
this.strategy.onApplicationStart?.(
schema,
MedusaModule.getAllJoinerConfigs()
)
}
protected onApplicationShutdown() {
this.strategy.onApplicationShutdown?.()
}
protected onApplicationPrepareShutdown() {
this.strategy.onApplicationPrepareShutdown?.()
}
protected static normalizeProviders(
providers: string[] | { id: string; ttl?: number }[]
): { id: string; ttl?: number }[] {
const providers_ = Array.isArray(providers) ? providers : [providers]
return providers_.map((provider) => {
return typeof provider === "string" ? { id: provider } : provider
})
}
protected getRequestKey(
key?: string,
tags?: string[],
providers?: string[]
): string {
const keyPart = key || ""
const tagsPart = tags?.sort().join(",") || ""
const providersPart = providers?.join(",") || this.defaultProviderId
return `${keyPart}|${tagsPart}|${providersPart}`
}
protected getClearRequestKey(
key?: string,
tags?: string[],
providers?: string[]
): string {
const keyPart = key || ""
const tagsPart = tags?.sort().join(",") || ""
const providersPart = providers?.join(",") || this.defaultProviderId
return `clear:${keyPart}|${tagsPart}|${providersPart}`
}
async get(options: { key?: string; tags?: string[]; providers?: string[] }) {
if (CachingModuleService.traceGet) {
return await CachingModuleService.traceGet(
() => this.get_(options),
options.key ?? "",
options.tags ?? []
)
}
return await this.get_(options)
}
private async get_({
key,
tags,
providers,
}: {
key?: string
tags?: string[]
providers?: string[]
}) {
if (!key && !tags) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Either key or tags must be provided"
)
}
const requestKey = this.getRequestKey(key, tags, providers)
const existingRequest = this.ongoingRequests.get(requestKey)
if (existingRequest) {
return await existingRequest
}
const requestPromise = this.performCacheGet(key, tags, providers)
this.ongoingRequests.set(requestKey, requestPromise)
try {
const result = await requestPromise
return result
} finally {
// Clean up the completed request
this.ongoingRequests.delete(requestKey)
}
}
protected async performCacheGet(
key?: string,
tags?: string[],
providers?: string[]
): Promise<any> {
const providersToCheck = providers ?? [this.defaultProviderId]
for (const providerId of providersToCheck) {
try {
const provider_ = this.providerService.retrieveProvider(providerId)
const result = await provider_.get({ key, tags })
if (result != null) {
return result
}
} catch (error) {
this.logger.warn(
`Cache provider ${providerId} failed: ${error.message}\n${error.stack}`
)
continue
}
}
return null
}
async set(options: {
key: string
data: object
ttl?: number
tags?: string[]
providers?: string[]
options?: { autoInvalidate?: boolean }
}) {
if (CachingModuleService.traceSet) {
return await CachingModuleService.traceSet(
() => this.set_(options),
options.key,
options.tags ?? [],
options.options ?? {}
)
}
return await this.set_(options)
}
private async set_({
key,
data,
ttl,
tags,
providers,
options,
}: {
key: string
data: object
tags?: string[]
ttl?: number
providers?: string[] | { id: string; ttl?: number }[]
options?: {
autoInvalidate?: boolean
}
}) {
if (!key) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"[CachingModuleService] Key must be provided"
)
}
const key_ = key
const tags_ = tags ?? (await this.strategy.computeTags(data))
let providers_: string[] | { id: string; ttl?: number }[] = [
this.defaultProviderId,
]
providers_ = CachingModuleService.normalizeProviders(
providers ?? providers_
)
const providerIds = providers_.map((p) => p.id)
const requestKey = this.getRequestKey(key_, tags_, providerIds)
const existingRequest = this.ongoingRequests.get(requestKey)
if (existingRequest) {
return await existingRequest
}
const requestPromise = this.performCacheSet(
key_,
tags_,
data,
ttl,
providers_,
options
)
this.ongoingRequests.set(requestKey, requestPromise)
try {
await requestPromise
} finally {
// Clean up the completed request
this.ongoingRequests.delete(requestKey)
}
}
protected async performCacheSet(
key: string,
tags: string[],
data: object,
ttl?: number,
providers?: { id: string; ttl?: number }[],
options?: {
autoInvalidate?: boolean
}
): Promise<void> {
for (const providerOptions of providers || []) {
const ttl_ = providerOptions.ttl ?? ttl ?? this.ttl
const provider = this.providerService.retrieveProvider(providerOptions.id)
void provider.set({
key,
tags,
data,
ttl: ttl_,
options,
})
}
}
async clear(options: {
key?: string
tags?: string[]
options?: { autoInvalidate?: boolean }
providers?: string[]
}) {
if (CachingModuleService.traceClear) {
return await CachingModuleService.traceClear(
() => this.clear_(options),
options.key ?? "",
options.tags ?? [],
options.options ?? {}
)
}
return await this.clear_(options)
}
private async clear_({
key,
tags,
options,
providers,
}: {
key?: string
tags?: string[]
options?: {
autoInvalidate?: boolean
}
providers?: string[]
}) {
if (!key && !tags) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Either key or tags must be provided"
)
}
const requestKey = this.getClearRequestKey(key, tags, providers)
const existingRequest = this.ongoingRequests.get(requestKey)
if (existingRequest) {
return await existingRequest
}
const requestPromise = this.performCacheClear(key, tags, options, providers)
this.ongoingRequests.set(requestKey, requestPromise)
try {
await requestPromise
} finally {
// Clean up the completed request
this.ongoingRequests.delete(requestKey)
}
}
protected async performCacheClear(
key?: string,
tags?: string[],
options?: {
autoInvalidate?: boolean
},
providers?: string[]
): Promise<void> {
let providerIds_: string[] = [this.defaultProviderId]
if (providers) {
providerIds_ = Array.isArray(providers) ? providers : [providers]
}
for (const providerId of providerIds_) {
const provider = this.providerService.retrieveProvider(providerId)
void provider.clear({ key, tags, options })
}
}
async computeKey(input: object): Promise<string> {
return await this.strategy.computeKey(input)
}
async computeTags(
input: object,
options?: Record<string, any>
): Promise<string[]> {
return await this.strategy.computeTags(input, options)
}
}
@@ -0,0 +1,60 @@
import {
Constructor,
ICachingProviderService,
Logger,
} from "@medusajs/framework/types"
import { MedusaError } from "@medusajs/framework/utils"
import { CachingProviderRegistrationPrefix } from "../types"
type InjectedDependencies = {
[key: `cp_${string}`]: ICachingProviderService
logger?: Logger
}
export default class CacheProviderService {
#container: InjectedDependencies
#logger: Logger
constructor(container: InjectedDependencies) {
this.#container = container
this.#logger = container["logger"]
? container.logger
: (console as unknown as Logger)
}
static getRegistrationIdentifier(
providerClass: Constructor<ICachingProviderService>
) {
if (!(providerClass as any).identifier) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
`Trying to register a caching provider without an identifier.`
)
}
return `${(providerClass as any).identifier}`
}
public retrieveProvider(providerId: string): ICachingProviderService {
try {
return this.#container[
`${CachingProviderRegistrationPrefix}${providerId}`
]
} catch (err) {
if (err.name === "AwilixResolutionError") {
const errMessage = `
Unable to retrieve the caching provider with id: ${providerId}
Please make sure that the provider is registered in the container and it is configured correctly in your project configuration file.`
// Log full error for debugging
this.#logger.error(`AwilixResolutionError: ${err.message}`, err)
throw new Error(errMessage)
}
const errMessage = `Unable to retrieve the caching provider with id: ${providerId}, the following error occurred: ${err.message}`
this.#logger.error(errMessage)
throw new Error(errMessage)
}
}
}
@@ -0,0 +1,2 @@
export { default as CachingModuleService } from "./cache-module"
export { default as CachingProviderService } from "./cache-provider"