chore(): Caching improvements (#13660)

* chore(): Caching improvements

* Create proud-windows-sleep.md

* naming

* fix tests

* address feedback
This commit is contained in:
Adrien de Peretti
2025-10-03 10:05:44 +02:00
committed by GitHub
parent 1789f3e257
commit d58462c9c9
7 changed files with 333 additions and 57 deletions

View File

@@ -48,6 +48,11 @@ jest.spyOn(MedusaModule, "getAllJoinerConfigs").mockReturnValue([
moduleIntegrationTestRunner<ICachingModuleService>({
moduleName: Modules.CACHING,
moduleOptions: {
in_memory: {
enable: true,
},
},
testSuite: ({ service }) => {
describe("Caching Module Service", () => {
beforeEach(async () => {

View File

@@ -61,6 +61,11 @@ moduleIntegrationTestRunner<ICachingModuleService>({
injectedDependencies: {
[Modules.EVENT_BUS]: mockEventBus,
},
moduleOptions: {
in_memory: {
enable: true,
},
},
testSuite: ({ service }) => {
describe("Cache Invalidation with Entity Relationships", () => {
afterEach(async () => {

View File

@@ -0,0 +1,159 @@
import { Modules } from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { ICachingModuleService } from "@medusajs/framework/types"
import { MedusaModule } from "@medusajs/framework/modules-sdk"
jest.setTimeout(10000)
jest.spyOn(MedusaModule, "getAllJoinerConfigs").mockReturnValue([
{
schema: `
type Product {
id: ID
title: String
handle: String
status: String
type_id: String
collection_id: String
is_giftcard: Boolean
external_id: String
created_at: DateTime
updated_at: DateTime
variants: [ProductVariant]
sales_channels: [SalesChannel]
}
type ProductVariant {
id: ID
product_id: String
sku: String
prices: [Price]
}
type Price {
id: ID
amount: Float
currency_code: String
}
type SalesChannel {
id: ID
is_disabled: Boolean
}
`,
},
])
moduleIntegrationTestRunner<ICachingModuleService>({
moduleName: Modules.CACHING,
moduleOptions: {
in_memory: {
enable: true,
maxSize: 1000,
},
},
testSuite: ({ service }) => {
describe("Memory Cache maxSize Integration", () => {
beforeEach(async () => {
await service.clear({ tags: ["*"] }).catch(() => {})
})
it("should respect maxSize limit and stop caching when exceeded", async () => {
// Create test data that will use most of the 1000 byte limit
const largeData = {
id: "large-test",
name: "Large Test Item",
description: "A".repeat(400), // 400 characters
metadata: { info: "B".repeat(300) }, // Another 300 characters
}
await service.set({
key: "first-key",
data: largeData,
tags: ["large-data"],
})
let result = await service.get({ key: "first-key" })
expect(result).toEqual(largeData)
const anotherLargeData = {
id: "another-large",
content: "C".repeat(500), // This should push us over the 1000 byte limit but accepted because we are still bellow
}
await service.set({
key: "second-key",
data: anotherLargeData,
tags: ["overflow-data"],
})
result = await service.get({ key: "second-key" })
expect(result).toEqual(anotherLargeData)
const anotherLargeData2 = {
id: "another-large-2",
content: "D".repeat(500), // This should push us over the 1000 byte limit but rejected because we are over the limit
}
await service.set({
key: "third-key",
data: anotherLargeData2,
tags: ["overflow-data-2"],
})
result = await service.get({ key: "third-key" })
expect(result).toBeNull()
const firstResult = await service.get({ key: "first-key" })
expect(firstResult).toEqual(largeData)
})
it("should allow small entries after reaching the limit", async () => {
const largeData = {
id: "large-test",
description: "A".repeat(800), // Use most of the 1000 byte limit
}
await service.set({
key: "large-key",
data: largeData,
tags: ["large"],
})
let result = await service.get({ key: "large-key" })
expect(result).toEqual(largeData)
await service.set({
key: "overflow-key",
data: { content: "B".repeat(500) },
})
result = await service.get({ key: "overflow-key" })
expect(result).toEqual({ content: "B".repeat(500) })
await service.set({
key: "overflow-key-2",
data: { content: "C".repeat(500) },
})
result = await service.get({ key: "overflow-key-2" })
expect(result).toBeNull()
// Remove the large entry to free up space
await service.clear({ key: "large-key" })
// Now small entries should work again
const smallData = { id: "small", name: "Small Item" }
await service.set({
key: "small-key",
data: smallData,
})
result = await service.get({ key: "small-key" })
expect(result).toEqual(smallData)
})
})
},
})

View File

@@ -49,21 +49,29 @@ export default async ({
const strategy = DefaultCacheStrategy // Re enable custom strategy another time
container.register("strategy", asValue(strategy))
// MemoryCachingProvider - default provider
container.register({
[CachingProviderRegistrationPrefix + MemoryCachingProvider.identifier]:
asFunction(() => new MemoryCachingProvider(), {
lifetime: Lifetime.SINGLETON,
}),
})
container.registerAdd(
CachingIdentifiersRegistrationName,
asValue(MemoryCachingProvider.identifier)
)
container.register(
CachingDefaultProvider,
asValue(MemoryCachingProvider.identifier)
)
const inMemoryOptions = options?.in_memory ?? {}
const { enable: isInMemoryEnabled, ...restInmemoryOptions } = inMemoryOptions
if (isInMemoryEnabled) {
// MemoryCachingProvider - default provider
container.register({
[CachingProviderRegistrationPrefix + MemoryCachingProvider.identifier]:
asFunction(
(cradle) => new MemoryCachingProvider(cradle, restInmemoryOptions),
{
lifetime: Lifetime.SINGLETON,
}
),
})
container.registerAdd(
CachingIdentifiersRegistrationName,
asValue(MemoryCachingProvider.identifier)
)
container.register(
CachingDefaultProvider,
asValue(MemoryCachingProvider.identifier)
)
}
// Load other providers
await moduleProviderLoader({
@@ -85,10 +93,16 @@ export default async ({
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
if (!hasDefaultProvider) {
logger.warn(
`[caching-module]: No default caching provider defined. Using "${container.resolve(
CachingDefaultProvider
)}" as default.`
)
if (isInMemoryEnabled) {
logger.warn(
`[caching-module]: No default caching provider defined. Using "${container.resolve(
CachingDefaultProvider
)}" as default.`
)
} else {
throw new Error(
"[caching-module]: No providers have been configured and the built in memory cache has not been enabled."
)
}
}
}

View File

@@ -1,40 +1,45 @@
import NodeCache from "node-cache"
import type { ICachingProviderService } from "@medusajs/framework/types"
import type { ICachingProviderService, Logger } from "@medusajs/framework/types"
import { MemoryCacheModuleOptions } from "@types"
export interface MemoryCacheModuleOptions {
/**
* TTL in seconds
*/
ttl?: number
/**
* Maximum number of keys to store (see node-cache documentation)
*/
maxKeys?: number
/**
* Check period for expired keys in seconds (see node-cache documentation)
*/
checkPeriod?: number
/**
* Use clones for cached data (see node-cache documentation)
*/
useClones?: boolean
const THREE_HUNDRED_MB = 300 * 1024 * 1024
const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${Math.round(bytes / 1024 / 1024)} MB`
}
export class MemoryCachingProvider implements ICachingProviderService {
static identifier = "cache-memory"
protected logger: Logger
protected cacheClient: NodeCache
protected tagIndex: Map<string, Set<string>> = new Map() // tag -> keys
protected keyTags: Map<string, Set<string>> = new Map() // key -> tags
protected entryOptions: Map<string, { autoInvalidate?: boolean }> = new Map() // key -> options
protected keySizes: Map<string, number> = new Map() // key -> approximate size in bytes
protected approximateMemoryUsage: number = 0
protected options: MemoryCacheModuleOptions
protected maxSize: number
protected hasher: (key: string) => string
constructor(
{ logger, hasher }: { logger?: Logger; hasher: (key: string) => string },
options: MemoryCacheModuleOptions = {}
) {
this.logger = logger ?? (console as unknown as Logger)
const { maxSize, ...rest } = options
this.maxSize = maxSize ?? THREE_HUNDRED_MB
this.hasher = hasher
constructor() {
this.options = {
ttl: 3600,
maxKeys: 25000,
checkPeriod: 60, // 10 minutes
useClones: false, // Default to false for speed, true would be slower but safer. we can discuss
useClones: false, // Default to false for speed, true would be slower but safer. we can ...discuss
...rest,
}
const cacheClient = new NodeCache({
@@ -47,15 +52,31 @@ export class MemoryCachingProvider implements ICachingProviderService {
this.cacheClient = cacheClient
// Clean up tag indices when keys expire
this.cacheClient.on("expired", (key: string, value: any) => {
this.cacheClient.on("expired", (key: string) => {
this.cleanupTagReferences(key)
})
this.cacheClient.on("del", (key: string, value: any) => {
this.cacheClient.on("del", (key: string) => {
this.cleanupTagReferences(key)
})
}
private calculateEntrySize(
key: string,
data: object,
tags?: string[]
): number {
const dataSize = Buffer.byteLength(JSON.stringify(data), "utf8")
const keySize = Buffer.byteLength(key, "utf8")
let tagsSize = 0
if (tags?.length) {
tagsSize = Buffer.byteLength(JSON.stringify(tags), "utf8")
}
return dataSize + keySize + tagsSize
}
private cleanupTagReferences(key: string): void {
const tags = this.keyTags.get(key)
if (tags) {
@@ -70,20 +91,30 @@ export class MemoryCachingProvider implements ICachingProviderService {
})
this.keyTags.delete(key)
}
// Clean up memory tracking
const keySize = this.keySizes.get(key)
if (keySize) {
this.approximateMemoryUsage -= keySize
this.keySizes.delete(key)
}
// Also clean up entry options
this.entryOptions.delete(key)
}
async get({ key, tags }: { key?: string; tags?: string[] }): Promise<any> {
if (key) {
return this.cacheClient.get(key) ?? null
const hashedKey = this.hasher(key)
return this.cacheClient.get(hashedKey) ?? null
}
if (tags && tags.length) {
if (tags?.length) {
const allKeys = new Set<string>()
tags.forEach((tag) => {
const keysForTag = this.tagIndex.get(tag)
const hashedTag = this.hasher(tag)
const keysForTag = this.tagIndex.get(hashedTag)
if (keysForTag) {
keysForTag.forEach((key) => allKeys.add(key))
}
@@ -122,24 +153,39 @@ export class MemoryCachingProvider implements ICachingProviderService {
autoInvalidate?: boolean
}
}): Promise<void> {
// Only reject if we're already over the limit
if (this.approximateMemoryUsage > this.maxSize) {
this.logger.warn(
`Cache is full. Current usage: ${formatBytes(
this.approximateMemoryUsage
)}, Max: ${formatBytes(this.maxSize)}. Skipping cache entry.`
)
return
}
const hashedKey = this.hasher(key)
const hashedTags = tags?.map((tag) => this.hasher(tag))
const totalSize = this.calculateEntrySize(hashedKey, data, hashedTags)
// Set the cache entry
const effectiveTTL = ttl ?? this.options.ttl ?? 3600
this.cacheClient.set(key, data, effectiveTTL)
this.cacheClient.set(hashedKey, data, effectiveTTL)
// Handle tags if provided
if (tags && tags.length) {
if (hashedTags?.length) {
// Clean up any existing tag references for this key
this.cleanupTagReferences(key)
this.cleanupTagReferences(hashedKey)
const tagSet = new Set(tags)
this.keyTags.set(key, tagSet)
const tagSet = new Set(hashedTags)
this.keyTags.set(hashedKey, tagSet)
// Add this key to each tag's index
tags.forEach((tag) => {
hashedTags.forEach((tag) => {
if (!this.tagIndex.has(tag)) {
this.tagIndex.set(tag, new Set())
}
this.tagIndex.get(tag)!.add(key)
this.tagIndex.get(tag)!.add(hashedKey)
})
}
@@ -148,8 +194,14 @@ export class MemoryCachingProvider implements ICachingProviderService {
Object.keys(options ?? {}).length &&
!Object.values(options ?? {}).every((value) => value === undefined)
) {
this.entryOptions.set(key, options!)
this.entryOptions.set(hashedKey, options!)
}
// Track memory usage
const existingSize = this.keySizes.get(hashedKey) || 0
this.approximateMemoryUsage =
this.approximateMemoryUsage - existingSize + totalSize
this.keySizes.set(hashedKey, totalSize)
}
async clear({
@@ -164,13 +216,14 @@ export class MemoryCachingProvider implements ICachingProviderService {
}
}): Promise<void> {
if (key) {
this.cacheClient.del(key)
const hashedKey = this.hasher(key)
this.cacheClient.del(hashedKey)
return
}
if (tags && tags.length) {
if (tags?.length) {
// Handle wildcard tag to clear all cache data
if (tags.includes("*")) {
if (tags?.includes("*")) {
this.cacheClient.flushAll()
this.tagIndex.clear()
this.keyTags.clear()
@@ -178,9 +231,10 @@ export class MemoryCachingProvider implements ICachingProviderService {
return
}
const hashedTags = tags.map((tag) => this.hasher(tag))
const allKeys = new Set<string>()
tags.forEach((tag) => {
hashedTags.forEach((tag) => {
const keysForTag = this.tagIndex.get(tag)
if (keysForTag) {
keysForTag.forEach((key) => allKeys.add(key))

View File

@@ -23,6 +23,31 @@ export type InjectedDependencies = {
[Modules.EVENT_BUS]: IEventBusModuleService
}
export interface MemoryCacheModuleOptions {
/**
* TTL in seconds
*/
ttl?: number
/**
* Maximum number of keys to store (see node-cache documentation)
*/
maxKeys?: number
/**
* Check period for expired keys in seconds (see node-cache documentation)
*/
checkPeriod?: number
/**
* Use clones for cached data (see node-cache documentation)
*/
useClones?: boolean
/**
* Maximum size of the cache in bytes (default 300MB).
* It is an approximation, if a new entry will make the limit exceeded, the entry will be cached
* but not the following ones
*/
maxSize?: number
}
export type CachingModuleOptions = Partial<ModuleServiceInitializeOptions> & {
/**
* The strategy to be used. Default to the inbuilt default strategy.
@@ -32,6 +57,15 @@ export type CachingModuleOptions = Partial<ModuleServiceInitializeOptions> & {
* Time to keep data in cache (in seconds)
*/
ttl?: number
/**
* Enable and configure the built in memory cache
* @private
*/
in_memory?: MemoryCacheModuleOptions & {
enable?: boolean
}
/**
* Providers to be registered
*/