Files
medusa-store/packages/modules/caching/src/providers/memory-cache.ts
Adrien de Peretti b9d6f73320 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>
2025-09-30 16:19:06 +00:00

229 lines
5.7 KiB
TypeScript

import NodeCache from "node-cache"
import type { ICachingProviderService } from "@medusajs/framework/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
}
export class MemoryCachingProvider implements ICachingProviderService {
static identifier = "cache-memory"
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 options: MemoryCacheModuleOptions
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
}
const cacheClient = new NodeCache({
stdTTL: this.options.ttl,
maxKeys: this.options.maxKeys,
checkperiod: this.options.checkPeriod,
useClones: this.options.useClones,
})
this.cacheClient = cacheClient
// Clean up tag indices when keys expire
this.cacheClient.on("expired", (key: string, value: any) => {
this.cleanupTagReferences(key)
})
this.cacheClient.on("del", (key: string, value: any) => {
this.cleanupTagReferences(key)
})
}
private cleanupTagReferences(key: string): void {
const tags = this.keyTags.get(key)
if (tags) {
tags.forEach((tag) => {
const keysForTag = this.tagIndex.get(tag)
if (keysForTag) {
keysForTag.delete(key)
if (keysForTag.size === 0) {
this.tagIndex.delete(tag)
}
}
})
this.keyTags.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
}
if (tags && tags.length) {
const allKeys = new Set<string>()
tags.forEach((tag) => {
const keysForTag = this.tagIndex.get(tag)
if (keysForTag) {
keysForTag.forEach((key) => allKeys.add(key))
}
})
if (allKeys.size === 0) {
return []
}
const results: any[] = []
allKeys.forEach((key) => {
const value = this.cacheClient.get(key)
if (value !== undefined) {
results.push(value)
}
})
return results
}
return null
}
async set({
key,
data,
ttl,
tags,
options,
}: {
key: string
data: object
ttl?: number
tags?: string[]
options?: {
autoInvalidate?: boolean
}
}): Promise<void> {
// Set the cache entry
const effectiveTTL = ttl ?? this.options.ttl ?? 3600
this.cacheClient.set(key, data, effectiveTTL)
// Handle tags if provided
if (tags && tags.length) {
// Clean up any existing tag references for this key
this.cleanupTagReferences(key)
const tagSet = new Set(tags)
this.keyTags.set(key, tagSet)
// Add this key to each tag's index
tags.forEach((tag) => {
if (!this.tagIndex.has(tag)) {
this.tagIndex.set(tag, new Set())
}
this.tagIndex.get(tag)!.add(key)
})
}
// Store entry options if provided
if (
Object.keys(options ?? {}).length &&
!Object.values(options ?? {}).every((value) => value === undefined)
) {
this.entryOptions.set(key, options!)
}
}
async clear({
key,
tags,
options,
}: {
key?: string
tags?: string[]
options?: {
autoInvalidate?: boolean
}
}): Promise<void> {
if (key) {
this.cacheClient.del(key)
return
}
if (tags && tags.length) {
// Handle wildcard tag to clear all cache data
if (tags.includes("*")) {
this.cacheClient.flushAll()
this.tagIndex.clear()
this.keyTags.clear()
this.entryOptions.clear()
return
}
const allKeys = new Set<string>()
tags.forEach((tag) => {
const keysForTag = this.tagIndex.get(tag)
if (keysForTag) {
keysForTag.forEach((key) => allKeys.add(key))
}
})
if (allKeys.size) {
// If no options provided (user explicit call), clear everything
if (!options) {
const keysToDelete = Array.from(allKeys)
this.cacheClient.del(keysToDelete)
// Clean up ALL tag references for deleted keys
keysToDelete.forEach((key) => {
this.cleanupTagReferences(key)
})
return
}
// If autoInvalidate is true (strategy call), only clear entries with autoInvalidate=true (default)
if (options.autoInvalidate === true) {
const keysToDelete: string[] = []
allKeys.forEach((key) => {
const entryOptions = this.entryOptions.get(key)
// Delete if entry has autoInvalidate=true or no setting (default true)
const shouldAutoInvalidate = entryOptions?.autoInvalidate ?? true
if (shouldAutoInvalidate) {
keysToDelete.push(key)
}
})
if (keysToDelete.length) {
this.cacheClient.del(keysToDelete)
// Clean up ALL tag references for deleted keys
keysToDelete.forEach((key) => {
this.cleanupTagReferences(key)
})
}
}
}
}
}
}