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 PerettiandCarlos R. L. Rodrigues authored and GitHub committed 2025-09-30 16:19:06 +00:00
1 parent 5b135a41fe
commit b9d6f73320
117 files changed
+5741 -530

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
/dist
node_modules
.DS_store
.env*
.env
*.sql
+1
View File
@@ -0,0 +1 @@
# @medusajs/caching
@@ -0,0 +1,51 @@
import {
EventBusTypes,
IEventBusModuleService,
Message,
Subscriber,
} from "@medusajs/types"
export class EventBusServiceMock implements IEventBusModuleService {
protected readonly subscribers_: Map<string | symbol, Set<Subscriber>> =
new Map()
async emit<T>(
messages: Message<T> | Message<T>[],
options?: Record<string, unknown>
): Promise<void> {
const messages_ = Array.isArray(messages) ? messages : [messages]
for (const message of messages_) {
const subscribers = this.subscribers_.get(message.name)
const starSubscribers = this.subscribers_.get("*")
for (const subscriber of [
...(subscribers ?? []),
...(starSubscribers ?? []),
]) {
const { options, ...payload } = message
await subscriber(payload)
}
}
}
subscribe(event: string | symbol, subscriber: Subscriber): this {
this.subscribers_.set(event, new Set([subscriber]))
return this
}
unsubscribe(
event: string | symbol,
subscriber: Subscriber,
context?: EventBusTypes.SubscriberContext
): this {
return this
}
releaseGroupedEvents(eventGroupId: string): Promise<void> {
throw new Error("Method not implemented.")
}
clearGroupedEvents(eventGroupId: string): Promise<void> {
throw new Error("Method not implemented.")
}
}
@@ -0,0 +1,336 @@
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,
testSuite: ({ service }) => {
describe("Caching Module Service", () => {
beforeEach(async () => {
await service.clear({ tags: ["*"] }).catch(() => {})
})
describe("Basic Cache Operations", () => {
it("should set and get cache data with default memory provider", async () => {
const testData = { id: "test-id", name: "Test Item" }
await service.set({
key: "test-key",
data: testData,
ttl: 3600,
})
const result = await service.get({ key: "test-key" })
expect(result).toEqual(testData)
})
it("should return null for non-existent keys", async () => {
const result = await service.get({ key: "non-existent" })
expect(result).toBeNull()
})
it("should handle tags-based storage and retrieval", async () => {
const testData1 = { id: "1", name: "Item 1" }
const testData2 = { id: "2", name: "Item 2" }
await service.set({
key: "item-1",
data: testData1,
tags: ["product", "active"],
})
await service.set({
key: "item-2",
data: testData2,
tags: ["product", "inactive"],
})
const productResults = await service.get({ tags: ["product"] })
expect(productResults).toHaveLength(2)
expect(productResults).toContainEqual(testData1)
expect(productResults).toContainEqual(testData2)
const activeResults = await service.get<any[]>({ tags: ["active"] })
expect(activeResults).toHaveLength(1)
expect(activeResults?.[0]).toEqual(testData1)
})
it("should clear cache by key", async () => {
await service.set({
key: "test-key",
data: { value: "test" },
})
await service.clear({ key: "test-key" })
const result = await service.get({ key: "test-key" })
expect(result).toBeNull()
})
it("should clear cache by tags", async () => {
await service.set({
key: "item-1",
data: { id: "1" },
tags: ["category-a"],
})
await service.set({
key: "item-2",
data: { id: "2" },
tags: ["category-b"],
})
await service.clear({ tags: ["category-a"] })
const result1 = await service.get({ key: "item-1" })
const result2 = await service.get({ key: "item-2" })
expect(result1).toBeNull()
expect(result2).toEqual({ id: "2" })
})
})
describe("Provider Priority", () => {
it("should check providers in order of priority when specified", async () => {
const testData = { id: "priority-test", name: "Priority Test" }
await service.set({
key: "priority-key",
data: testData,
providers: ["cache-memory"],
})
const result = await service.get({
key: "priority-key",
providers: ["cache-memory"],
})
expect(result).toEqual(testData)
})
it("should return null when providers array is empty or invalid", async () => {
const result = await service.get({
key: "test-key",
providers: [],
})
expect(result).toBeNull()
})
})
describe("Promise Deduplication", () => {
it("should deduplicate concurrent get requests with same parameters", async () => {
const testData = { id: "concurrent-test", name: "Concurrent Test" }
await service.set({
key: "concurrent-key",
data: testData,
})
const promises = Array.from({ length: 5 }, () =>
service.get<any>({ key: "concurrent-key" })
)
const results = await Promise.all(promises)
results.forEach((result) => {
expect(result).toEqual(testData)
})
})
it("should deduplicate concurrent get requests with same tags", async () => {
const testData = { id: "tag-test", name: "Tag Test" }
await service.set({
key: "tag-key",
data: testData,
tags: ["concurrent-tag"],
})
const promises = Array.from({ length: 5 }, () =>
service.get<any[]>({ tags: ["concurrent-tag"] })
)
const results = await Promise.all(promises)
results.forEach((result) => {
expect(result).toHaveLength(1)
expect(result?.[0]).toEqual(testData)
})
})
it("should deduplicate concurrent clear requests", async () => {
await service.set({
key: "clear-test-1",
data: { id: "1" },
tags: ["clear-tag"],
})
await service.set({
key: "clear-test-2",
data: { id: "2" },
tags: ["clear-tag"],
})
const promises = Array.from({ length: 3 }, () =>
service.clear({ tags: ["clear-tag"] })
)
await Promise.all(promises)
const result1 = await service.get({ key: "clear-test-1" })
const result2 = await service.get({ key: "clear-test-2" })
expect(result1).toBeNull()
expect(result2).toBeNull()
})
it("should handle concurrent requests with different parameters separately", async () => {
const testData1 = { id: "1", name: "Item 1" }
const testData2 = { id: "2", name: "Item 2" }
await service.set({ key: "key-1", data: testData1 })
await service.set({ key: "key-2", data: testData2 })
const promises = [
service.get({ key: "key-1" }),
service.get({ key: "key-1" }),
service.get({ key: "key-2" }),
service.get({ key: "key-2" }),
]
const results = await Promise.all(promises)
expect(results[0]).toEqual(testData1)
expect(results[1]).toEqual(testData1)
expect(results[2]).toEqual(testData2)
expect(results[3]).toEqual(testData2)
})
})
describe("Memory Cache Provider Integration", () => {
it("should respect TTL settings", async () => {
const testData = { id: "ttl-test", name: "TTL Test" }
await service.set({
key: "ttl-key",
data: testData,
ttl: 1,
})
let result = await service.get({ key: "ttl-key" })
expect(result).toEqual(testData)
await new Promise((resolve) => setTimeout(resolve, 1100))
result = await service.get({ key: "ttl-key" })
expect(result).toBeNull()
})
it("should handle autoInvalidate option", async () => {
const testData = { id: "no-auto-test", name: "No Auto Test" }
await service.set({
key: "no-auto-key",
data: testData,
tags: ["no-auto-tag"],
options: { autoInvalidate: false },
})
await service.clear({
tags: ["no-auto-tag"],
options: { autoInvalidate: true },
})
const result = await service.get({ key: "no-auto-key" })
expect(result).toEqual(testData)
await service.clear({
tags: ["no-auto-tag"],
})
const result2 = await service.get({ key: "no-auto-key" })
expect(result2).toBeNull()
})
it("should generate consistent cache keys", async () => {
const testInput = { userId: "123", action: "view" }
const key1 = await service.computeKey(testInput)
const key2 = await service.computeKey(testInput)
expect(key1).toBe(key2)
expect(typeof key1).toBe("string")
expect(key1.length).toBeGreaterThan(0)
})
it("should generate cache tags", async () => {
const testInput = { id: "prod_1", title: "123", description: "456" }
const tags = await service.computeTags(testInput)
expect(Array.isArray(tags)).toBe(true)
expect(tags.length).toBeGreaterThan(0)
})
})
describe("Error Handling", () => {
it("should throw error when neither key nor tags provided to get", async () => {
await expect(service.get({})).rejects.toThrow(
"Either key or tags must be provided"
)
})
it("should throw error when neither key nor tags provided to clear", async () => {
await expect(service.clear({})).rejects.toThrow(
"Either key or tags must be provided"
)
})
})
})
},
})
@@ -0,0 +1,430 @@
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"
import { EventBusServiceMock } from "../__fixtures__/event-bus-mock"
jest.setTimeout(30000)
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
variant_id: String
}
type SalesChannel {
id: ID
is_disabled: Boolean
}
type ProductCollection {
id: ID
title: String
handle: String
}
`,
},
])
const mockEventBus = new EventBusServiceMock()
moduleIntegrationTestRunner<ICachingModuleService>({
moduleName: Modules.CACHING,
injectedDependencies: {
[Modules.EVENT_BUS]: mockEventBus,
},
testSuite: ({ service }) => {
describe("Cache Invalidation with Entity Relationships", () => {
afterEach(async () => {
await service.clear({ tags: ["*"] }).catch(() => {})
})
describe("Single Entity Caching", () => {
it("should cache and retrieve a single product entity using computed keys", async () => {
const product = {
id: "prod_1",
title: "Test Product",
handle: "test-product",
status: "published",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
const productKey = await service.computeKey(product)
await service.set({
key: productKey,
data: product,
})
const cachedProduct = await service.get({ key: productKey })
expect(cachedProduct).toEqual(product)
})
it("should auto-invalidate single entity when strategy clears computed tags", async () => {
const product = {
id: "prod_1",
title: "Test Product",
handle: "test-product",
}
const productKey = await service.computeKey(product)
await service.set({
key: productKey,
data: product,
})
await mockEventBus.emit(
[{ name: "product.updated", data: { id: product.id } }],
{}
)
const result = await service.get({ key: productKey })
expect(result).toBeNull()
})
it("should not auto-invalidate single entity with autoInvalidate=false", async () => {
const product = {
id: "prod_1",
title: "Test Product",
handle: "test-product",
}
const productKey = await service.computeKey(product)
await service.set({
key: productKey,
data: product,
options: { autoInvalidate: false },
})
await mockEventBus.emit(
[{ name: "product.updated", data: { id: product.id } }],
{}
)
const result = await service.get({ key: productKey })
expect(result).toEqual(product)
})
})
describe("Entity List Caching", () => {
it("should cache and retrieve lists of entities using computed keys", async () => {
const publishedProductsQuery = {
entity: "product",
filters: { status: "published" },
fields: ["id", "title", "status"],
}
const allProductsQuery = {
entity: "product",
filters: {},
fields: ["id", "title", "status"],
}
const publishedProducts = [
{ id: "prod_1", title: "Product 1", status: "published" },
{ id: "prod_2", title: "Product 2", status: "published" },
]
const allProducts = [
...publishedProducts,
{ id: "prod_3", title: "Product 3", status: "draft" },
]
const publishedProductsKey = await service.computeKey(
publishedProductsQuery
)
const allProductsKey = await service.computeKey(allProductsQuery)
await service.set({
key: publishedProductsKey,
data: publishedProducts,
})
await service.set({
key: allProductsKey,
data: allProducts,
})
const cachedPublished = await service.get({
key: publishedProductsKey,
})
const cachedAll = await service.get({ key: allProductsKey })
expect(cachedPublished).toEqual(publishedProducts)
expect(cachedAll).toEqual(allProducts)
})
it("should invalidate related lists when individual product is updated", async () => {
const listQuery = {
entity: "product",
filters: { status: "published" },
includes: ["id", "title"],
}
const products = [
{ id: "prod_1", title: "Product 1", status: "published" },
{ id: "prod_2", title: "Product 2", status: "published" },
]
const listKey = await service.computeKey(listQuery)
await service.set({
key: listKey,
data: products,
})
await mockEventBus.emit(
[
{
name: "product.updated",
data: { id: "prod_1", title: "Updated Product 1" },
},
],
{}
)
const cachedList = await service.get({ key: listKey })
expect(cachedList).toBeNull()
})
})
describe("Nested Entity Caching", () => {
it("should cache products with nested variants and prices using computed keys", async () => {
const productWithVariants = {
id: "prod_1",
title: "Complex Product",
variants: [
{
id: "var_1",
product_id: "prod_1",
sku: "SKU-001",
prices: [
{
id: "price_1",
variant_id: "var_1",
amount: 1000,
currency_code: "USD",
},
{
id: "price_2",
variant_id: "var_1",
amount: 900,
currency_code: "EUR",
},
],
},
{
id: "var_2",
product_id: "prod_1",
sku: "SKU-002",
prices: [
{
id: "price_3",
variant_id: "var_2",
amount: 1200,
currency_code: "USD",
},
],
},
],
}
const productKey = await service.computeKey(productWithVariants)
await service.set({
key: productKey,
data: productWithVariants,
})
const cached = await service.get<typeof productWithVariants>({
key: productKey,
})
expect(cached).toEqual(productWithVariants)
expect(cached!.variants).toHaveLength(2)
expect(cached!.variants[0].prices).toHaveLength(2)
})
it("should invalidate nested product when related variant is updated", async () => {
const productWithVariants = {
id: "prod_1",
title: "Complex Product",
variants: [{ id: "var_1", product_id: "prod_1", sku: "SKU-001" }],
}
const productKey = await service.computeKey(productWithVariants)
await service.set({
key: productKey,
data: productWithVariants,
})
await mockEventBus.emit(
[
{
name: "product_variant.updated",
data: {
id: "var_1",
product_id: "prod_1",
sku: "SKU-001-UPDATED",
},
},
],
{}
)
const result = await service.get({ key: productKey })
expect(result).toBeNull()
})
it("should handle price updates affecting variant and product caches", async () => {
const price = {
id: "price_1",
variant_id: "var_1",
amount: 1000,
currency_code: "USD",
}
const variant = {
id: "var_1",
product_id: "prod_1",
sku: "SKU-001",
prices: [price],
}
const product = {
id: "prod_1",
title: "Product",
variants: [variant],
}
const priceKey = await service.computeKey(price)
const variantKey = await service.computeKey(variant)
const productKey = await service.computeKey(product)
await service.set({
key: priceKey,
data: price,
})
await service.set({
key: variantKey,
data: variant,
})
await service.set({
key: productKey,
data: product,
})
await mockEventBus.emit(
[
{
name: "price.updated",
data: { id: "price_1", variant_id: "var_1", amount: 1100 },
},
],
{}
)
const cachedPrice = await service.get({ key: priceKey })
const cachedVariant = await service.get({ key: variantKey })
const cachedProduct = await service.get({ key: productKey })
expect(cachedPrice).toBeNull()
expect(cachedVariant).toBeNull()
expect(cachedProduct).toBeNull()
})
})
describe("Complex Query Caching", () => {
it("should cache complex queries and invalidate based on entity relationships", async () => {
const complexQuery = {
entity: "product",
filters: { status: "published", collection_id: "col_1" },
includes: {
variants: {
include: {
prices: true,
},
},
collection: true,
},
pagination: { limit: 10, offset: 0 },
}
const queryResult = {
data: [
{
id: "prod_1",
title: "Product 1",
collection_id: "col_1",
variants: [
{
id: "var_1",
product_id: "prod_1",
prices: [{ id: "price_1", amount: 1000 }],
},
],
collection: { id: "col_1", title: "Collection 1" },
},
],
pagination: { total: 1, limit: 10, offset: 0 },
}
const queryKey = await service.computeKey(complexQuery)
await service.set({
key: queryKey,
data: queryResult,
})
const cached = await service.get({ key: queryKey })
expect(cached).toEqual(queryResult)
await mockEventBus.emit(
[
{
name: "price.updated",
data: { id: "price_1", amount: 1100 },
},
],
{}
)
const cachedAfterUpdate = await service.get({ key: queryKey })
expect(cachedAfterUpdate).toBeNull()
})
})
})
},
})
@@ -0,0 +1,540 @@
import { MedusaModule } from "@medusajs/framework/modules-sdk"
import { ICachingModuleService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { setTimeout } from "timers/promises"
import { EventBusServiceMock } from "../../__fixtures__/event-bus-mock"
jest.setTimeout(300000)
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
variant_id: String
}
type SalesChannel {
id: ID
is_disabled: Boolean
}
type ProductCollection {
id: ID
title: String
handle: String
}
`,
},
])
const DEFAULT_WAIT_INTERVAL = 50
const mockEventBus = new EventBusServiceMock()
moduleIntegrationTestRunner<ICachingModuleService>({
moduleName: Modules.CACHING,
injectedDependencies: {
[Modules.EVENT_BUS]: mockEventBus,
},
moduleOptions: {
providers: [
{
id: "cache-redis",
resolve: require.resolve("../../../../providers/caching-redis/src"),
is_default: true,
options: {
redisUrl: "localhost:6379",
},
},
],
},
testSuite: ({ service }) => {
describe("Cache Invalidation with Entity Relationships", () => {
afterEach(async () => {
await service.clear({ tags: ["*"] }).catch(() => {})
})
describe("Single Entity Caching", () => {
it("should cache and retrieve a single product entity using computed keys", async () => {
const product = {
id: "prod_1",
title: "Test Product",
handle: "test-product",
status: "published",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
const productKey = await service.computeKey(product)
await service.set({
key: productKey,
data: product,
})
await setTimeout(DEFAULT_WAIT_INTERVAL)
const cachedProduct = await service.get({ key: productKey })
expect(cachedProduct).toEqual(product)
})
it("should auto-invalidate single entity when strategy clears computed tags", async () => {
const product = {
id: "prod_1",
title: "Test Product",
handle: "test-product",
}
const productKey = await service.computeKey(product)
await service.set({
key: productKey,
data: product,
})
await setTimeout(DEFAULT_WAIT_INTERVAL)
await mockEventBus.emit(
[{ name: "product.updated", data: { id: product.id } }],
{}
)
await setTimeout(DEFAULT_WAIT_INTERVAL)
const result = await service.get({ key: productKey })
expect(result).toBeNull()
})
it("should not auto-invalidate single entity with autoInvalidate=false", async () => {
const product = {
id: "prod_1",
title: "Test Product",
handle: "test-product",
}
const productKey = await service.computeKey(product)
await service.set({
key: productKey,
data: product,
options: { autoInvalidate: false },
})
await setTimeout(DEFAULT_WAIT_INTERVAL)
await mockEventBus.emit(
[{ name: "product.updated", data: { id: product.id } }],
{}
)
await setTimeout(DEFAULT_WAIT_INTERVAL)
const result = await service.get({ key: productKey })
expect(result).toEqual(product)
})
})
describe("Entity List Caching", () => {
it("should cache and retrieve lists of entities using computed keys", async () => {
const publishedProductsQuery = {
entity: "product",
filters: { status: "published" },
fields: ["id", "title", "status"],
}
const allProductsQuery = {
entity: "product",
filters: {},
fields: ["id", "title", "status"],
}
const publishedProducts = [
{ id: "prod_1", title: "Product 1", status: "published" },
{ id: "prod_2", title: "Product 2", status: "published" },
]
const allProducts = [
...publishedProducts,
{ id: "prod_3", title: "Product 3", status: "draft" },
]
const publishedProductsKey = await service.computeKey(
publishedProductsQuery
)
const allProductsKey = await service.computeKey(allProductsQuery)
await service.set({
key: publishedProductsKey,
data: publishedProducts,
})
await service.set({
key: allProductsKey,
data: allProducts,
})
await setTimeout(DEFAULT_WAIT_INTERVAL)
const cachedPublished = await service.get({
key: publishedProductsKey,
})
const cachedAll = await service.get({ key: allProductsKey })
expect(cachedPublished).toEqual(publishedProducts)
expect(cachedAll).toEqual(allProducts)
})
it("should invalidate related lists when individual product is updated", async () => {
const listQuery = {
entity: "product",
filters: { status: "published" },
includes: ["id", "title"],
}
const products = [
{ id: "prod_1", title: "Product 1", status: "published" },
{ id: "prod_2", title: "Product 2", status: "published" },
]
const listKey = await service.computeKey(listQuery)
await service.set({
key: listKey,
data: products,
})
await setTimeout(DEFAULT_WAIT_INTERVAL)
await mockEventBus.emit(
[
{
name: "product.updated",
data: { id: "prod_1", title: "Updated Product 1" },
},
],
{}
)
await setTimeout(DEFAULT_WAIT_INTERVAL)
const cachedList = await service.get({ key: listKey })
expect(cachedList).toBeNull()
})
})
describe("Nested Entity Caching", () => {
it("should cache products with nested variants and prices using computed keys", async () => {
const productWithVariants = {
id: "prod_1",
title: "Complex Product",
variants: [
{
id: "var_1",
product_id: "prod_1",
sku: "SKU-001",
prices: [
{
id: "price_1",
variant_id: "var_1",
amount: 1000,
currency_code: "USD",
},
{
id: "price_2",
variant_id: "var_1",
amount: 900,
currency_code: "EUR",
},
],
},
{
id: "var_2",
product_id: "prod_1",
sku: "SKU-002",
prices: [
{
id: "price_3",
variant_id: "var_2",
amount: 1500,
currency_code: "USD",
},
],
},
],
}
const productKey = await service.computeKey(productWithVariants)
await service.set({
key: productKey,
data: productWithVariants,
})
await setTimeout(DEFAULT_WAIT_INTERVAL)
const cached = await service.get<typeof productWithVariants>({
key: productKey,
})
expect(cached).toEqual(productWithVariants)
expect(cached!.variants).toHaveLength(2)
expect(cached!.variants[0].prices).toHaveLength(2)
})
it("should invalidate nested product when related variant is updated", async () => {
const productWithVariants = {
id: "prod_1",
title: "Complex Product",
variants: [{ id: "var_1", product_id: "prod_1", sku: "SKU-001" }],
}
const productKey = await service.computeKey(productWithVariants)
await service.set({
key: productKey,
data: productWithVariants,
})
await setTimeout(DEFAULT_WAIT_INTERVAL)
await mockEventBus.emit(
[
{
name: "product_variant.updated",
data: {
id: "var_1",
product_id: "prod_1",
sku: "SKU-001-UPDATED",
},
},
],
{}
)
await setTimeout(DEFAULT_WAIT_INTERVAL)
const result = await service.get({ key: productKey })
expect(result).toBeNull()
})
it("should handle price updates affecting variant and product caches", async () => {
const price = {
id: "price_1",
variant_id: "var_1",
amount: 1000,
currency_code: "USD",
}
const variant = {
id: "var_1",
product_id: "prod_1",
sku: "SKU-001",
prices: [price],
}
const product = {
id: "prod_1",
title: "Product",
variants: [variant],
}
const priceKey = await service.computeKey(price)
const variantKey = await service.computeKey(variant)
const productKey = await service.computeKey(product)
await service.set({
key: priceKey,
data: price,
})
await service.set({
key: variantKey,
data: variant,
})
await service.set({
key: productKey,
data: product,
})
await setTimeout(DEFAULT_WAIT_INTERVAL)
await mockEventBus.emit(
[
{
name: "price.updated",
data: { id: "price_1", variant_id: "var_1", amount: 1100 },
},
],
{}
)
await setTimeout(DEFAULT_WAIT_INTERVAL)
const cachedPrice = await service.get({ key: priceKey })
const cachedVariant = await service.get({ key: variantKey })
const cachedProduct = await service.get({ key: productKey })
expect(cachedPrice).toBeNull()
expect(cachedVariant).toBeNull()
expect(cachedProduct).toBeNull()
})
})
describe("Complex Query Caching", () => {
it("should cache complex queries and invalidate based on entity relationships", async () => {
const complexQuery = {
entity: "product",
filters: { status: "published", collection_id: "col_1" },
includes: {
variants: {
include: {
prices: true,
},
},
collection: true,
},
pagination: { limit: 10, offset: 0 },
}
const queryResult = {
data: [
{
id: "prod_1",
title: "Product 1",
collection_id: "col_1",
variants: [
{
id: "var_1",
product_id: "prod_1",
prices: [{ id: "price_1", amount: 1000 }],
},
],
collection: { id: "col_1", title: "Collection 1" },
},
],
pagination: { total: 1, limit: 10, offset: 0 },
}
const queryKey = await service.computeKey(complexQuery)
await service.set({
key: queryKey,
data: queryResult,
})
await setTimeout(DEFAULT_WAIT_INTERVAL)
const cached = await service.get({ key: queryKey })
expect(cached).toEqual(queryResult)
await mockEventBus.emit(
[
{
name: "price.updated",
data: { id: "price_1", amount: 1100 },
},
],
{}
)
await setTimeout(DEFAULT_WAIT_INTERVAL)
const cachedAfterUpdate = await service.get({ key: queryKey })
expect(cachedAfterUpdate).toBeNull()
})
})
it("should cache complex queries and return the correct cached data", async () => {
const complexQuery = {
entity: "product",
filters: { status: "published", collection_id: "col_1" },
includes: {
variants: {
include: {
prices: true,
},
},
collection: true,
},
pagination: { limit: 10, offset: 0 },
}
const queryResult = {
data: [
{
id: "prod_1",
title: "Product 1",
collection_id: "col_1",
variants: [
{
id: "var_1",
product_id: "prod_1",
prices: [{ id: "price_1", amount: 1000 }],
},
],
collection: { id: "col_1", title: "Collection 1" },
},
],
pagination: { total: 1, limit: 10, offset: 0 },
}
const queryKey = await service.computeKey(complexQuery)
await service.set({
key: queryKey,
data: queryResult,
})
await setTimeout(DEFAULT_WAIT_INTERVAL)
const cached = await service.get({ key: queryKey })
expect(cached).toEqual(queryResult)
const complexQueryOffset = {
entity: "product",
filters: { status: "published", collection_id: "col_1" },
includes: {
variants: {
include: {
prices: true,
},
},
collection: true,
},
pagination: { limit: 10, offset: 10 },
}
const queryKeyOffset = await service.computeKey(complexQueryOffset)
const cachedOffset = await service.get({ key: queryKeyOffset })
expect(cachedOffset).toEqual(null)
})
})
},
})
+8
View File
@@ -0,0 +1,8 @@
const defineJestConfig = require("../../../define_jest_config")
module.exports = defineJestConfig({
moduleNameMapper: {
"^@services": "<rootDir>/src/services",
"^@types": "<rootDir>/src/types",
"^@utils": "<rootDir>/src/utils",
},
})
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@medusajs/caching",
"version": "2.10.3",
"description": "Caching Module for Medusa",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/modules/caching"
},
"files": [
"dist",
"!dist/**/__tests__",
"!dist/**/__mocks__",
"!dist/**/__fixtures__"
],
"publishConfig": {
"access": "public"
},
"author": "Medusa",
"license": "MIT",
"scripts": {
"watch": "tsc --build --watch",
"watch:test": "tsc --build tsconfig.spec.json --watch",
"resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json",
"build": "rimraf dist && tsc --build && npm run resolve:aliases",
"test": "jest --passWithNoTests --runInBand --bail --forceExit -- src/",
"test:integration": "jest --runInBand --forceExit -- integration-tests/__tests__/**/*.ts"
},
"devDependencies": {
"@medusajs/framework": "2.10.3",
"@medusajs/test-utils": "2.10.3",
"@swc/core": "^1.7.28",
"@swc/jest": "^0.2.36",
"jest": "^29.7.0",
"rimraf": "^3.0.2",
"tsc-alias": "^1.8.6",
"typescript": "^5.6.2"
},
"peerDependencies": {
"@medusajs/framework": "2.10.3",
"awilix": "^8.0.1"
},
"dependencies": {
"fast-json-stable-stringify": "^2.1.0",
"node-cache": "^5.1.2",
"xxhash-wasm": "^1.1.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module, Modules } from "@medusajs/framework/utils"
import { default as loadHash } from "./loaders/hash"
import { default as loadProviders } from "./loaders/providers"
import CachingModuleService from "./services/cache-module"
export default Module(Modules.CACHING, {
service: CachingModuleService,
loaders: [loadHash, loadProviders],
})
// Module options types
export { CachingModuleOptions } from "./types"
@@ -0,0 +1,8 @@
import { asValue } from "awilix"
export default async ({ container }) => {
const xxhashhWasm = await import("xxhash-wasm")
const { h32ToString } = await xxhashhWasm.default()
container.register("hasher", asValue(h32ToString))
}
@@ -0,0 +1,94 @@
import { moduleProviderLoader } from "@medusajs/framework/modules-sdk"
import { LoaderOptions, ModulesSdkTypes } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
getProviderRegistrationKey,
} from "@medusajs/framework/utils"
import { CachingProviderService } from "@services"
import {
CachingDefaultProvider,
CachingIdentifiersRegistrationName,
CachingModuleOptions,
CachingProviderRegistrationPrefix,
} from "@types"
import { aliasTo, asFunction, asValue, Lifetime } from "awilix"
import { MemoryCachingProvider } from "../providers/memory-cache"
import { DefaultCacheStrategy } from "../utils/strategy"
const registrationFn = async (klass, container, { id }) => {
const key = CachingProviderService.getRegistrationIdentifier(klass)
if (!id) {
throw new Error(`No "id" provided for provider ${key}`)
}
const regKey = getProviderRegistrationKey({
providerId: id,
providerIdentifier: key,
})
container.register({
[CachingProviderRegistrationPrefix + id]: aliasTo(regKey),
})
container.registerAdd(CachingIdentifiersRegistrationName, asValue(key))
}
export default async ({
container,
options,
}: LoaderOptions<
(
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
) &
CachingModuleOptions
>): Promise<void> => {
container.registerAdd(CachingIdentifiersRegistrationName, asValue(undefined))
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)
)
// Load other providers
await moduleProviderLoader({
container,
providers: options?.providers || [],
registerServiceFn: registrationFn,
})
const isSingleProvider = options?.providers?.length === 1
let hasDefaultProvider = false
for (const provider of options?.providers || []) {
if (provider.is_default || isSingleProvider) {
if (provider.is_default) {
hasDefaultProvider = true
}
container.register(CachingDefaultProvider, asValue(provider.id))
}
}
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
if (!hasDefaultProvider) {
logger.warn(
`[caching-module]: No default caching provider defined. Using "${container.resolve(
CachingDefaultProvider
)}" as default.`
)
}
}
@@ -0,0 +1,228 @@
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)
})
}
}
}
}
}
}
@@ -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"
@@ -0,0 +1,56 @@
import type {
Constructor,
ICachingStrategy,
IEventBusModuleService,
Logger,
ModuleProviderExports,
ModuleServiceInitializeOptions,
} from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { default as CacheProviderService } from "../services/cache-provider"
export const CachingDefaultProvider = "default_provider"
export const CachingIdentifiersRegistrationName = "caching_providers_identifier"
export const CachingProviderRegistrationPrefix = "lp_"
export type InjectedDependencies = {
cacheProviderService: CacheProviderService
hasher: (data: string) => string
logger?: Logger
strategy: Constructor<ICachingStrategy>
[CachingDefaultProvider]: string
[Modules.EVENT_BUS]: IEventBusModuleService
}
export type CachingModuleOptions = Partial<ModuleServiceInitializeOptions> & {
/**
* The strategy to be used. Default to the inbuilt default strategy.
*/
// strategy?: ICachingStrategy
/**
* Time to keep data in cache (in seconds)
*/
ttl?: number
/**
* Providers to be registered
*/
providers?: {
/**
* The module provider to be registered
*/
resolve: string | ModuleProviderExports
/**
* If the provider is the default
*/
is_default?: boolean
/**
* The id of the provider
*/
id: string
/**
* key value pair of the configuration to be passed to the provider constructor
*/
options?: Record<string, unknown>
}[]
}
@@ -0,0 +1,487 @@
import { GraphQLSchema, buildSchema } from "graphql"
import { CacheInvalidationParser, EntityReference } from "../parser"
describe("CacheInvalidationParser", () => {
let parser: CacheInvalidationParser
let schema: GraphQLSchema
beforeEach(() => {
const schemaDefinition = `
type Product {
id: ID!
title: String
description: String
collection: ProductCollection
categories: [ProductCategory!]
variants: [ProductVariant!]
created_at: String
updated_at: String
}
type ProductCollection {
id: ID!
title: String
products: [Product!]
created_at: String
updated_at: String
}
type ProductCategory {
id: ID!
name: String
products: [Product!]
parent: ProductCategory
children: [ProductCategory!]
created_at: String
updated_at: String
}
type ProductVariant {
id: ID!
title: String
sku: String
product: Product!
prices: [Price!]
created_at: String
updated_at: String
}
type Price {
id: ID!
amount: Int
currency_code: String
variant: ProductVariant!
created_at: String
updated_at: String
}
type Order {
id: ID!
status: String
items: [OrderItem!]
customer: Customer
created_at: String
updated_at: String
}
type OrderItem {
id: ID!
quantity: Int
order: Order!
variant: ProductVariant!
created_at: String
updated_at: String
}
type Customer {
id: ID!
first_name: String
last_name: String
email: String
orders: [Order!]
created_at: String
updated_at: String
}
`
schema = buildSchema(schemaDefinition)
parser = new CacheInvalidationParser(schema, [
// Partially populate this record ro force the test to match from both id prefix or type
// detection
{
idPrefixToEntityName: {
prod: "Product",
col: "ProductCollection",
cat: "ProductCategory",
},
},
])
})
describe("parseObjectForEntities", () => {
it("should identify a simple product entity", () => {
const product = {
id: "prod_123",
title: "Test Product",
description: "A test product",
}
const entities = parser.parseObjectForEntities(product)
expect(entities).toHaveLength(1)
expect(entities[0]).toEqual({
type: "Product",
id: "prod_123",
isInArray: false,
})
})
it("should identify nested entities in a product with collection", () => {
const product = {
id: "prod_123",
title: "Test Product",
collection: {
id: "col_456",
title: "Test Collection",
},
}
const entities = parser.parseObjectForEntities(product)
expect(entities).toHaveLength(2)
expect(entities).toContainEqual({
type: "Product",
id: "prod_123",
isInArray: false,
})
expect(entities).toContainEqual({
type: "ProductCollection",
id: "col_456",
isInArray: false,
})
})
it("should identify entities in arrays", () => {
const product = {
id: "prod_123",
title: "Test Product",
variants: [
{
id: "var_789",
title: "Variant 1",
sku: "SKU-001",
},
{
id: "var_790",
title: "Variant 2",
sku: "SKU-002",
},
],
}
const entities = parser.parseObjectForEntities(product)
expect(entities).toHaveLength(3)
expect(entities).toContainEqual({
type: "Product",
id: "prod_123",
isInArray: false,
})
expect(entities).toContainEqual({
type: "ProductVariant",
id: "var_789",
isInArray: true,
})
expect(entities).toContainEqual({
type: "ProductVariant",
id: "var_790",
isInArray: true,
})
})
it("should handle deeply nested entities", () => {
const order = {
id: "order_123",
status: "completed",
items: [
{
id: "item_456",
quantity: 2,
variant: {
id: "var_789",
title: "Variant 1",
product: {
id: "prod_123",
title: "Test Product",
collection: {
id: "col_456",
title: "Test Collection",
},
},
},
},
],
customer: {
id: "cus_789",
email: "test@example.com",
first_name: "John",
},
}
const entities = parser.parseObjectForEntities(order)
expect(entities).toHaveLength(6)
expect(entities).toContainEqual({
type: "Order",
id: "order_123",
isInArray: false,
})
expect(entities).toContainEqual({
type: "OrderItem",
id: "item_456",
isInArray: true,
})
expect(entities).toContainEqual({
type: "ProductVariant",
id: "var_789",
isInArray: false,
})
expect(entities).toContainEqual({
type: "Product",
id: "prod_123",
isInArray: false,
})
expect(entities).toContainEqual({
type: "ProductCollection",
id: "col_456",
isInArray: false,
})
expect(entities).toContainEqual({
type: "Customer",
id: "cus_789",
isInArray: false,
})
})
it("should return empty array for null or primitive values", () => {
expect(parser.parseObjectForEntities(null)).toEqual([])
expect(parser.parseObjectForEntities(undefined)).toEqual([])
expect(parser.parseObjectForEntities("string")).toEqual([])
expect(parser.parseObjectForEntities(123)).toEqual([])
expect(parser.parseObjectForEntities(true)).toEqual([])
})
it("should ignore objects without id field", () => {
const invalidObject = {
title: "No ID Object",
description: "This object has no ID",
}
const entities = parser.parseObjectForEntities(invalidObject)
expect(entities).toEqual([])
})
it("should handle objects with partial field matches", () => {
const partialProduct = {
id: "prod_123",
title: "Test Product",
unknown_field: "Should still work",
}
const entities = parser.parseObjectForEntities(partialProduct)
expect(entities).toHaveLength(1)
expect(entities[0]).toEqual({
type: "Product",
id: "prod_123",
isInArray: false,
})
})
})
describe("buildInvalidationEvents", () => {
it("should build invalidation events for a single entity", () => {
const entities: EntityReference[] = [{ type: "Product", id: "prod_123" }]
const events = parser.buildInvalidationEvents(entities)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
entityType: "Product",
entityId: "prod_123",
relatedEntities: [],
})
expect(events[0].cacheKeys).toEqual(["Product:prod_123"])
})
it("should build invalidation events with related entities", () => {
const entities: EntityReference[] = [
{ type: "Product", id: "prod_123" },
{ type: "ProductCollection", id: "col_456" },
{ type: "ProductVariant", id: "var_789" },
]
const events = parser.buildInvalidationEvents(entities)
expect(events).toHaveLength(3)
const productEvent = events.find((e) => e.entityType === "Product")
expect(productEvent).toBeDefined()
expect(productEvent!.relatedEntities).toHaveLength(2)
expect(productEvent!.cacheKeys).toEqual(["Product:prod_123"])
})
it("should avoid duplicate entities in events", () => {
const entities: EntityReference[] = [
{ type: "Product", id: "prod_123" },
{ type: "Product", id: "prod_123" }, // Duplicate
{ type: "ProductCollection", id: "col_456" },
]
const events = parser.buildInvalidationEvents(entities)
expect(events).toHaveLength(2) // Should only have Product and ProductCollection events
expect(events.map((e) => e.entityType).sort()).toEqual([
"Product",
"ProductCollection",
])
})
it("should generate comprehensive cache keys", () => {
const entities: EntityReference[] = [
{ type: "Product", id: "prod_123" },
{ type: "ProductCollection", id: "col_456" },
]
const events = parser.buildInvalidationEvents(entities)
const productEvent = events.find((e) => e.entityType === "Product")!
expect(productEvent.cacheKeys).toEqual(["Product:prod_123"])
})
})
describe("integration scenarios", () => {
it("should handle a complete product updated scenario", () => {
const productData = {
id: "prod_123",
title: "Updated Product Title",
collection: {
id: "col_456",
title: "Fashion Collection",
},
categories: [
{ id: "cat_789", name: "Shirts" },
{ id: "cat_790", name: "Casual" },
],
variants: [
{
id: "var_111",
title: "Size S",
prices: [{ id: "price_222", amount: 2999, currency_code: "USD" }],
},
],
}
const entities = parser.parseObjectForEntities(productData)
const events = parser.buildInvalidationEvents(entities)
// Should identify all nested entities
expect(entities).toHaveLength(6) // Product, Collection, 2 Categories, Variant, Price
// Should created events for each entity type
expect(events).toHaveLength(6)
// Validate cache keys for each entity type
const productEvent = events.find((e) => e.entityType === "Product")!
expect(productEvent.cacheKeys).toEqual(["Product:prod_123"])
const collectionEvent = events.find(
(e) => e.entityType === "ProductCollection"
)!
expect(collectionEvent.cacheKeys).toEqual(["ProductCollection:col_456"])
const categoryEvents = events.filter(
(e) => e.entityType === "ProductCategory"
)
expect(categoryEvents).toHaveLength(2)
expect(categoryEvents[0].cacheKeys).toEqual([
"ProductCategory:cat_789",
"ProductCategory:list:*",
])
expect(categoryEvents[1].cacheKeys).toEqual([
"ProductCategory:cat_790",
"ProductCategory:list:*",
])
const variantEvent = events.find(
(e) => e.entityType === "ProductVariant"
)!
expect(variantEvent.cacheKeys).toEqual([
"ProductVariant:var_111",
"ProductVariant:list:*",
])
const priceEvent = events.find((e) => e.entityType === "Price")!
expect(priceEvent.cacheKeys).toEqual(["Price:price_222", "Price:list:*"])
})
it("should handle order with customer and items scenario", () => {
const orderData = {
id: "order_123",
status: "completed",
customer: {
id: "cus_456",
email: "customer@example.com",
},
items: [
{
id: "item_789",
quantity: 2,
variant: {
id: "var_111",
sku: "SHIRT-S-BLUE",
},
},
],
}
const entities = parser.parseObjectForEntities(orderData)
const events = parser.buildInvalidationEvents(entities)
expect(entities).toHaveLength(4) // Order, Customer, OrderItem, ProductVariant
expect(events).toHaveLength(4)
// Validate cache keys for each entity type
const orderEvent = events.find((e) => e.entityType === "Order")!
expect(orderEvent.cacheKeys).toEqual(["Order:order_123"])
const customerEvent = events.find((e) => e.entityType === "Customer")!
expect(customerEvent.cacheKeys).toEqual(["Customer:cus_456"])
const itemEvent = events.find((e) => e.entityType === "OrderItem")!
expect(itemEvent.cacheKeys).toEqual([
"OrderItem:item_789",
"OrderItem:list:*",
])
const variantEvent = events.find(
(e) => e.entityType === "ProductVariant"
)!
expect(variantEvent.cacheKeys).toEqual(["ProductVariant:var_111"])
})
it("should include simplified cache keys for created operation", () => {
const entities: EntityReference[] = [{ type: "Product", id: "prod_123" }]
const events = parser.buildInvalidationEvents(entities, "created")
const productEvent = events[0]
expect(productEvent.cacheKeys).toEqual([
"Product:prod_123",
"Product:list:*",
])
})
it("should include simplified cache keys for deleted operation", () => {
const entities: EntityReference[] = [{ type: "Product", id: "prod_123" }]
const events = parser.buildInvalidationEvents(entities, "deleted")
const productEvent = events[0]
expect(productEvent.cacheKeys).toEqual([
"Product:prod_123",
"Product:list:*",
])
})
it("should include simplified cache keys for updated operation", () => {
const entities: EntityReference[] = [{ type: "Product", id: "prod_123" }]
const events = parser.buildInvalidationEvents(entities, "updated")
const productEvent = events[0]
expect(productEvent.cacheKeys).toEqual(["Product:prod_123"])
})
})
})
@@ -0,0 +1,242 @@
import { ModuleJoinerConfig } from "@medusajs/framework/types"
import { isObject } from "@medusajs/framework/utils"
import {
GraphQLObjectType,
GraphQLSchema,
isListType,
isNonNullType,
isObjectType,
} from "graphql"
export interface EntityReference {
type: string
id: string | number
field?: string
isInArray?: boolean
}
export interface InvalidationEvent {
entityType: string
entityId: string | number
relatedEntities: EntityReference[]
cacheKeys: string[]
}
export class CacheInvalidationParser {
private typeMap: Map<string, GraphQLObjectType>
private idPrefixToEntityName: Record<string, string>
constructor(schema: GraphQLSchema, joinerConfigs: ModuleJoinerConfig[]) {
this.typeMap = new Map()
// Build type map for quick lookups
const schemaTypeMap = schema.getTypeMap()
Object.keys(schemaTypeMap).forEach((typeName) => {
const type = schemaTypeMap[typeName]
if (isObjectType(type) && !typeName.startsWith("__")) {
this.typeMap.set(typeName, type)
}
})
this.idPrefixToEntityName = joinerConfigs.reduce((acc, joinerConfig) => {
if (joinerConfig.idPrefixToEntityName) {
Object.entries(joinerConfig.idPrefixToEntityName).forEach(
([idPrefix, entityName]) => {
acc[idPrefix] = entityName
}
)
}
return acc
}, {} as Record<string, string>)
}
/**
* Parse an object to identify entities and their relationships
*/
parseObjectForEntities(
obj: any,
parentType?: string,
isInArray: boolean = false
): EntityReference[] {
const entities: EntityReference[] = []
if (!obj || typeof obj !== "object") {
return entities
}
// Check if this object matches any known GraphQL types
const detectedType = this.detectEntityType(obj, parentType)
if (detectedType && obj.id) {
entities.push({
type: detectedType,
id: obj.id,
isInArray,
})
}
// Recursively parse nested objects and arrays
Object.keys(obj).forEach((key) => {
const value = obj[key]
if (Array.isArray(value)) {
value.forEach((item) => {
entities.push(
...this.parseObjectForEntities(
item,
this.getRelationshipType(detectedType, key),
true
)
)
})
} else if (isObject(value)) {
entities.push(
...this.parseObjectForEntities(
value,
this.getRelationshipType(detectedType, key),
false
)
)
}
})
return entities
}
/**
* Detect entity type based on object structure and GraphQL type map
*/
private detectEntityType(obj: any, suggestedType?: string): string | null {
if (obj.id) {
const idParts = obj.id.split("_")
if (idParts.length > 1 && this.idPrefixToEntityName[idParts[0]]) {
return this.idPrefixToEntityName[idParts[0]]
}
}
if (suggestedType && this.typeMap.has(suggestedType)) {
const type = this.typeMap.get(suggestedType)!
if (this.objectMatchesType(obj, type)) {
return suggestedType
}
}
// Try to match against all known types
for (const [typeName, type] of this.typeMap) {
if (this.objectMatchesType(obj, type)) {
return typeName
}
}
return null
}
/**
* Check if object structure matches GraphQL type fields
*/
private objectMatchesType(obj: any, type: GraphQLObjectType): boolean {
const fields = type.getFields()
const objKeys = Object.keys(obj)
// Must have id field for entities
if (!obj.id || !fields.id) {
return false
}
// Check if at least 50% of non-null object fields match type fields
const matchingFields = objKeys.filter((key) => fields[key]).length
return matchingFields >= Math.max(1, objKeys.length * 0.5)
}
/**
* Get the expected type for a relationship field
*/
private getRelationshipType(
parentType: string | null,
fieldName: string
): string | undefined {
if (!parentType || !this.typeMap.has(parentType)) {
return undefined
}
const type = this.typeMap.get(parentType)!
const field = type.getFields()[fieldName]
if (!field) {
return undefined
}
let fieldType = field.type
// Unwrap NonNull and List wrappers
if (isNonNullType(fieldType)) {
fieldType = fieldType.ofType
}
if (isListType(fieldType)) {
fieldType = fieldType.ofType
}
if (isNonNullType(fieldType)) {
fieldType = fieldType.ofType
}
if (isObjectType(fieldType)) {
return fieldType.name
}
return undefined
}
/**
* Build invalidation events based on parsed entities
*/
buildInvalidationEvents(
entities: EntityReference[],
operation: "created" | "updated" | "deleted" = "updated"
): InvalidationEvent[] {
const events: InvalidationEvent[] = []
const processedEntities = new Set<string>()
entities.forEach((entity) => {
const entityKey = `${entity.type}:${entity.id}`
if (processedEntities.has(entityKey)) {
return
}
processedEntities.add(entityKey)
const relatedEntities = entities.filter(
(e) => e.type !== entity.type || e.id !== entity.id
)
const affectedKeys = this.buildAffectedCacheKeys(entity, operation)
events.push({
entityType: entity.type,
entityId: entity.id,
relatedEntities,
cacheKeys: affectedKeys,
})
})
return events
}
/**
* Build list of cache keys that should be invalidated
*/
private buildAffectedCacheKeys(
entity: EntityReference,
operation: "created" | "updated" | "deleted" = "updated"
): string[] {
const keys = new Set<string>()
keys.add(`${entity.type}:${entity.id}`)
// Add list key only if entity was found in an array context or if an event of type created or
// deleted is triggered
if (entity.isInArray || ["created", "deleted"].includes(operation)) {
keys.add(`${entity.type}:list:*`)
}
return Array.from(keys)
}
}
@@ -0,0 +1,133 @@
import type {
Event,
ICachingModuleService,
ICachingStrategy,
ModuleJoinerConfig,
} from "@medusajs/framework/types"
import {
type GraphQLSchema,
Modules,
toCamelCase,
upperCaseFirst,
} from "@medusajs/framework/utils"
import { type CachingModuleService } from "@services"
import type { InjectedDependencies } from "@types"
import stringify from "fast-json-stable-stringify"
import { CacheInvalidationParser, EntityReference } from "./parser"
export class DefaultCacheStrategy implements ICachingStrategy {
#cacheInvalidationParser: CacheInvalidationParser
#cacheModule: ICachingModuleService
#container: InjectedDependencies
#hasher: (data: string) => string
constructor(
container: InjectedDependencies,
cacheModule: CachingModuleService
) {
this.#cacheModule = cacheModule
this.#container = container
this.#hasher = container.hasher
}
objectHash(input: any): string {
const str = stringify(input)
return this.#hasher(str)
}
async onApplicationStart(
schema: GraphQLSchema,
joinerConfigs: ModuleJoinerConfig[]
) {
this.#cacheInvalidationParser = new CacheInvalidationParser(
schema,
joinerConfigs
)
const eventBus = this.#container[Modules.EVENT_BUS]
const handleEvent = async (data: Event) => {
try {
// We dont have to await anything here and the rest can be done in the background
return
} finally {
const eventName = data.name
const operation = eventName.split(".").pop() as
| "created"
| "updated"
| "deleted"
const entityType = eventName.split(".").slice(-2).shift()!
const eventData = data.data as
| { id: string | string[] }
| { id: string | string[] }[]
const normalizedEventData = Array.isArray(eventData)
? eventData
: [eventData]
const tags: string[] = []
for (const item of normalizedEventData) {
const ids = Array.isArray(item.id) ? item.id : [item.id]
for (const id of ids) {
const entityReference: EntityReference = {
type: upperCaseFirst(toCamelCase(entityType)),
id,
}
const tags_ = await this.computeTags(item, {
entities: [entityReference],
operation,
})
tags.push(...tags_)
}
}
void this.#cacheModule.clear({
tags,
options: { autoInvalidate: true },
})
}
}
eventBus.subscribe("*", handleEvent)
eventBus.addInterceptor?.(handleEvent)
}
async computeKey(input: object) {
return this.objectHash(input)
}
async computeTags(
input: object,
options?: {
entities?: EntityReference[]
operation?: "created" | "updated" | "deleted"
}
): Promise<string[]> {
// Parse the input object to identify entities
const entities_ =
options?.entities ||
this.#cacheInvalidationParser.parseObjectForEntities(input)
if (entities_.length === 0) {
return []
}
// Build invalidation events to get comprehensive cache keys
const events = this.#cacheInvalidationParser.buildInvalidationEvents(
entities_,
options?.operation
)
// Collect all unique cache keys from all events as tags
const tags = new Set<string>()
events.forEach((event) => {
event.cacheKeys.forEach((key) => tags.add(key))
})
return Array.from(tags)
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../_tsconfig.base.json",
"compilerOptions": {
"paths": {
"@services": ["./src/services"],
"@types": ["./src/types"],
"@utils": ["./src/utils"]
}
}
}
@@ -58,8 +58,9 @@ export default class LocalEventBusService extends AbstractEventBusModuleService
const eventListenersCount = this.eventEmitter_.listenerCount(
eventData.name
)
const startSubscribersCount = this.eventEmitter_.listenerCount("*")
if (eventListenersCount === 0) {
if (eventListenersCount === 0 && startSubscribersCount === 0) {
continue
}
@@ -84,6 +85,7 @@ export default class LocalEventBusService extends AbstractEventBusModuleService
private async groupOrEmitEvent<T = unknown>(eventData: Message<T>) {
const { options, ...eventBody } = eventData
const eventGroupId = eventBody.metadata?.eventGroupId
const hasStarSubscriber = this.eventEmitter_.listenerCount("*") > 0
if (eventGroupId) {
await this.groupEvent(eventGroupId, eventData)
@@ -91,9 +93,15 @@ export default class LocalEventBusService extends AbstractEventBusModuleService
const options_ = eventData.options as { delay: number }
const delay = (ms?: number) => (ms ? setTimeout(ms) : Promise.resolve())
delay(options_?.delay).then(() =>
delay(options_?.delay).then(async () => {
// Call interceptors before emitting
void this.callInterceptors(eventData, { isGrouped: false })
this.eventEmitter_.emit(eventData.name, eventBody)
)
if (hasStarSubscriber) {
this.eventEmitter_.emit("*", eventBody)
}
})
}
}
@@ -112,6 +120,7 @@ export default class LocalEventBusService extends AbstractEventBusModuleService
async releaseGroupedEvents(eventGroupId: string) {
let groupedEvents = this.groupedEventsMap_.get(eventGroupId) || []
groupedEvents = JSON.parse(JSON.stringify(groupedEvents))
const hasStarSubscriber = this.eventEmitter_.listenerCount("*") > 0
for (const event of groupedEvents) {
const { options, ...eventBody } = event
@@ -119,9 +128,15 @@ export default class LocalEventBusService extends AbstractEventBusModuleService
const options_ = options as { delay: number }
const delay = (ms?: number) => (ms ? setTimeout(ms) : Promise.resolve())
delay(options_?.delay).then(() =>
delay(options_?.delay).then(async () => {
// Call interceptors before emitting grouped events
void this.callInterceptors(event, { isGrouped: true, eventGroupId })
this.eventEmitter_.emit(event.name, eventBody)
)
if (hasStarSubscriber) {
this.eventEmitter_.emit("*", eventBody)
}
})
}
await this.clearGroupedEvents(eventGroupId)
@@ -158,6 +158,10 @@ export default class RedisEventBusService extends AbstractEventBusModuleService
const promises: Promise<unknown>[] = []
if (eventsToEmit.length) {
eventsToEmit.map((eventData) =>
this.callInterceptors(eventData, { isGrouped: false })
)
const emitData = this.buildEvents(eventsToEmit, options)
promises.push(this.queue_.addBulk(emitData))
@@ -213,6 +217,21 @@ export default class RedisEventBusService extends AbstractEventBusModuleService
async releaseGroupedEvents(eventGroupId: string) {
const groupedEvents = await this.getGroupedEvents(eventGroupId)
// Call interceptors before emitting grouped events
// Extract the original messages from the job data structure
groupedEvents.map((jobData) => {
// Reconstruct the message from the job data
const message = {
name: jobData.name,
data: jobData.data,
metadata: jobData.data.metadata,
}
this.callInterceptors(message as any, {
isGrouped: true,
eventGroupId,
})
})
await this.queue_.addBulk(groupedEvents)
await this.clearGroupedEvents(eventGroupId)
@@ -1,6 +0,0 @@
import { defineMikroOrmCliConfig, Modules } from "@medusajs/framework/utils"
import * as entities from "./src/models"
export default defineMikroOrmCliConfig(Modules.LOCKING, {
entities: Object.values(entities),
})
@@ -0,0 +1,49 @@
{
"name": "@medusajs/caching-redis",
"version": "2.10.3",
"description": "Redis Caching for Medusa",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/modules/providers/caching-redis"
},
"files": [
"dist",
"!dist/**/__tests__",
"!dist/**/__mocks__",
"!dist/**/__fixtures__"
],
"engines": {
"node": ">=20"
},
"author": "Medusa",
"license": "MIT",
"devDependencies": {
"@medusajs/framework": "2.10.3",
"@swc/core": "^1.7.28",
"@swc/jest": "^0.2.36",
"jest": "^29.7.0",
"rimraf": "^5.0.1",
"typescript": "^5.6.2"
},
"peerDependencies": {
"@medusajs/framework": "2.10.3"
},
"dependencies": {
"ioredis": "^5.4.1",
"xxhash-wasm": "^1.1.0"
},
"scripts": {
"watch": "tsc --build --watch",
"watch:test": "tsc --build tsconfig.spec.json --watch",
"resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json",
"build": "rimraf dist && tsc --build && npm run resolve:aliases",
"test": "jest --passWithNoTests src",
"test:integration": "jest --forceExit --passWithNoTests"
},
"keywords": [
"medusa-providers",
"medusa-providers-cache"
]
}
@@ -0,0 +1,12 @@
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
import Connection from "./loaders/connection"
import Hash from "./loaders/hash"
import { RedisCachingProvider } from "./services/redis-cache"
const services = [RedisCachingProvider]
const loaders = [Connection, Hash]
export default ModuleProvider(Modules.CACHING, {
services,
loaders,
})
@@ -0,0 +1,66 @@
import type {
InternalModuleDeclaration,
LoaderOptions,
ModulesSdkTypes,
} from "@medusajs/framework/types"
import { RedisCacheModuleOptions } from "@types"
import Redis from "ioredis"
export default async (
{
container,
logger,
options,
}: LoaderOptions<
(
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
) & { logger?: any }
>,
moduleDeclaration?: InternalModuleDeclaration
): Promise<void> => {
const logger_ = logger || console
const moduleOptions = (options ??
moduleDeclaration?.options ??
{}) as RedisCacheModuleOptions & {
redisUrl?: string
}
if (!moduleOptions.redisUrl) {
throw new Error("[caching-redis] redisUrl is required")
}
let redisClient: Redis
try {
redisClient = new Redis(moduleOptions.redisUrl!, {
connectTimeout: 10000,
lazyConnect: true,
retryDelayOnFailover: 100,
connectionName: "medusa-cache-redis",
...moduleOptions,
})
// Test connection
await redisClient.ping()
logger_.info("Redis cache connection established successfully")
} catch (error) {
logger_.error(`Failed to connect to Redis cache: ${error.message}`)
redisClient = new Redis(moduleOptions.redisUrl!, {
connectTimeout: 10000,
lazyConnect: true,
retryDelayOnFailover: 100,
...moduleOptions,
})
}
container.register({
redisClient: {
resolve: () => redisClient,
},
prefix: {
resolve: () => moduleOptions.prefix ?? "mc:",
},
})
}
@@ -0,0 +1,8 @@
import { asValue } from "awilix"
export default async ({ container }) => {
const xxhashhWasm = await import("xxhash-wasm")
const { h32ToString } = await xxhashhWasm.default()
container.register("hasher", asValue(h32ToString))
}
@@ -0,0 +1,683 @@
import { RedisCacheModuleOptions } from "@types"
import { Redis } from "ioredis"
import { createGunzip, createGzip } from "zlib"
export class RedisCachingProvider {
static identifier = "cache-redis"
protected redisClient: Redis
protected keyNamePrefix: string
protected defaultTTL: number
protected compressionThreshold: number
protected hasher: (key: string) => string
constructor(
{
redisClient,
prefix,
hasher,
}: { redisClient: Redis; prefix: string; hasher: (key: string) => string },
options?: RedisCacheModuleOptions
) {
this.redisClient = redisClient
this.keyNamePrefix = prefix
this.defaultTTL = options?.ttl ?? 3600 // 1 hour default
this.compressionThreshold = options?.compressionThreshold ?? 2048 // 2KB default
this.hasher = hasher
}
#getKeyName(key: string): string {
return `${this.keyNamePrefix}${key}`
}
#getTagKey(
tag: string,
{ isHashed = false }: { isHashed?: boolean } = {}
): string {
return `${this.keyNamePrefix}tag:${isHashed ? tag : this.hasher(tag)}`
}
#getTagsKey(key: string): string {
return `${this.keyNamePrefix}tags:${key}`
}
#getTagDictionaryKey(): string {
return `${this.keyNamePrefix}tag:dictionary`
}
#getTagNextIdKey(): string {
return `${this.keyNamePrefix}tag:next_id`
}
#getTagRefCountKey(): string {
return `${this.keyNamePrefix}tag:refs`
}
#getTagReverseDictionaryKey(): string {
return `${this.keyNamePrefix}tag:reverse_dict`
}
async #internTags(tags: string[]): Promise<number[]> {
const pipeline = this.redisClient.pipeline()
const dictionaryKey = this.#getTagDictionaryKey()
const hashedTags = tags.map((tag) => this.hasher(tag))
// Get existing tag IDs
hashedTags.forEach((tag) => {
pipeline.hget(dictionaryKey, tag)
})
const results = await pipeline.exec()
const tagIds: number[] = []
const newTags: string[] = []
for (let i = 0; i < hashedTags.length; i++) {
const result = results?.[i]
if (result && result[1]) {
tagIds[i] = parseInt(result[1] as string)
} else {
const hashedTag = hashedTags[i]
newTags.push(hashedTag)
tagIds[i] = -1 // Placeholder for new tags
}
}
// Create IDs for new tags
if (newTags.length) {
const nextIdKey = this.#getTagNextIdKey()
const reverseDictKey = this.#getTagReverseDictionaryKey()
const refCountKey = this.#getTagRefCountKey()
const startId = await this.redisClient.incrby(nextIdKey, newTags.length)
const batchPipeline = this.redisClient.pipeline()
newTags.forEach((tag, index) => {
const newId = startId - newTags.length + index + 1
// Store in both forward and reverse dictionaries
batchPipeline.hset(dictionaryKey, tag, newId.toString())
batchPipeline.hset(reverseDictKey, newId.toString(), tag)
// Update the tagIds array
const originalIndex = hashedTags.indexOf(tag)
tagIds[originalIndex] = newId
})
// Add reference count increments to the same pipeline
tagIds.forEach((id) => {
if (id !== -1) {
batchPipeline.hincrby(refCountKey, id.toString(), 1)
}
})
await batchPipeline.exec()
} else {
// Only increment reference count for existing tags
const refCountKey = this.#getTagRefCountKey()
const refPipeline = this.redisClient.pipeline()
tagIds.forEach((id) => {
refPipeline.hincrby(refCountKey, id.toString(), 1)
})
await refPipeline.exec()
}
return tagIds
}
async #resolveTagIds(tagIds: number[]): Promise<string[]> {
if (tagIds.length === 0) return []
const reverseDictKey = this.#getTagReverseDictionaryKey()
const pipeline = this.redisClient.pipeline()
tagIds.forEach((id) => {
pipeline.hget(reverseDictKey, id.toString())
})
const results = await pipeline.exec()
return results?.map((result) => result?.[1] as string).filter(Boolean) || []
}
async #decrementTagRefs(tagIds: number[]): Promise<void> {
if (tagIds.length === 0) return
const refCountKey = this.#getTagRefCountKey()
const dictionaryKey = this.#getTagDictionaryKey()
// Decrement reference counts and collect tags with zero refs
const pipeline = this.redisClient.pipeline()
tagIds.forEach((id) => {
pipeline.hincrby(refCountKey, id.toString(), -1)
})
const results = await pipeline.exec()
const tagsToCleanup: number[] = []
// Find tags that now have zero references
results?.forEach((result, index) => {
if (result && result[1] === 0) {
tagsToCleanup.push(tagIds[index])
}
})
// Clean up tags with zero references
if (tagsToCleanup.length) {
const cleanupPipeline = this.redisClient.pipeline()
const reverseDictKey = this.#getTagReverseDictionaryKey()
// Get tag names before deleting them
const tagNames = await this.#resolveTagIds(tagsToCleanup)
tagsToCleanup.forEach((id, index) => {
const idStr = id.toString()
// Remove from reference count hash
cleanupPipeline.hdel(refCountKey, idStr)
// Remove from reverse dictionary
cleanupPipeline.hdel(reverseDictKey, idStr)
// Remove from forward dictionary
if (tagNames[index]) {
cleanupPipeline.hdel(dictionaryKey, tagNames[index])
}
})
await cleanupPipeline.exec()
}
}
async #compressData(data: string): Promise<Buffer> {
if (data.length <= this.compressionThreshold) {
const buffer = Buffer.from(data, "utf8")
const prefix = Buffer.from([0]) // 0 = uncompressed
return Buffer.concat([prefix, buffer])
}
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
const gzip = createGzip()
gzip.on("data", (chunk) => chunks.push(chunk))
gzip.on("end", () => {
const compressedBuffer = Buffer.concat(chunks)
const prefix = Buffer.from([1]) // 1 = compressed
resolve(Buffer.concat([prefix, compressedBuffer]))
})
gzip.on("error", (error) => {
const buffer = Buffer.from(data, "utf8")
const prefix = Buffer.from([0])
resolve(Buffer.concat([prefix, buffer]))
})
gzip.write(data, "utf8")
gzip.end()
})
}
async #decompressData(buffer: Buffer): Promise<string> {
if (buffer.length === 0) {
return ""
}
const formatByte = buffer[0]
const dataBuffer = buffer.subarray(1)
if (formatByte === 0) {
// Uncompressed
return dataBuffer.toString("utf8")
}
if (formatByte === 1) {
// Compressed with gzip
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
const gunzip = createGunzip()
gunzip.on("data", (chunk) => chunks.push(chunk))
gunzip.on("end", () => {
const decompressed = Buffer.concat(chunks).toString("utf8")
resolve(decompressed)
})
gunzip.on("error", (error) => {
// Fallback: return as-is if decompression fails
resolve(dataBuffer.toString("utf8"))
})
gunzip.write(dataBuffer)
gunzip.end()
})
}
// Unknown format, return as UTF-8
return buffer.toString("utf8")
}
async get({ key, tags }: { key?: string; tags?: string[] }): Promise<any> {
if (key) {
const keyName = this.#getKeyName(key)
const buffer = await this.redisClient.hgetBuffer(keyName, "data")
if (!buffer) {
return null
}
const finalData = await this.#decompressData(buffer)
return JSON.parse(finalData)
}
if (tags?.length) {
// Get all keys associated with the tags
const pipeline = this.redisClient.pipeline()
tags.forEach((tag) => {
const tagKey = this.#getTagKey(tag)
pipeline.smembers(tagKey)
})
const tagResults = await pipeline.exec()
const allKeys = new Set<string>()
tagResults?.forEach((result, index) => {
if (result && result[1]) {
;(result[1] as string[]).forEach((key) => allKeys.add(key))
}
})
if (allKeys.size === 0) {
return []
}
// Get all hash data for the keys
const valuePipeline = this.redisClient.pipeline()
Array.from(allKeys).forEach((key) => {
valuePipeline.hgetBuffer(key, "data")
})
const valueResults = await valuePipeline.exec()
const results: any[] = []
const decompressionPromises = (valueResults || []).map(async (result) => {
if (result && result[1]) {
const buffer = result[1] as Buffer
try {
const finalData = await this.#decompressData(buffer)
return JSON.parse(finalData)
} catch (e) {
// If JSON parsing fails, skip this entry (corrupted data)
console.warn(`Skipping corrupted cache entry: ${e.message}`)
return null
}
}
return null
})
const decompressionResults = await Promise.all(decompressionPromises)
results.push(...decompressionResults.filter(Boolean))
return results
}
return null
}
async set({
key,
data,
ttl,
tags,
options,
}: {
key: string
data: object
ttl?: number
tags?: string[]
options?: {
autoInvalidate?: boolean
}
}): Promise<void> {
const keyName = this.#getKeyName(key)
const serializedData = JSON.stringify(data)
const effectiveTTL = ttl ?? this.defaultTTL
const finalData = await this.#compressData(serializedData)
let tagIds: number[] = []
if (tags?.length) {
tagIds = await this.#internTags(tags)
}
const setPipeline = this.redisClient.pipeline()
// Main data with conditional operations
setPipeline.hsetnx(keyName, "data", finalData)
if (options && Object.keys(options).length) {
setPipeline.hset(keyName, "options", JSON.stringify(options))
}
if (effectiveTTL) {
setPipeline.expire(keyName, effectiveTTL)
}
// Store tag IDs if present
if (tags?.length && tagIds.length) {
const tagsKey = this.#getTagsKey(key)
const buffer = Buffer.alloc(tagIds.length * 4)
tagIds.forEach((id, index) => {
buffer.writeUInt32LE(id, index * 4)
})
if (effectiveTTL) {
setPipeline.set(tagsKey, buffer, "EX", effectiveTTL + 60, "NX")
} else {
setPipeline.setnx(tagsKey, buffer)
}
// Add tag operations to the same pipeline
tags.forEach((tag) => {
const tagKey = this.#getTagKey(tag)
setPipeline.sadd(tagKey, keyName)
if (effectiveTTL) {
setPipeline.expire(tagKey, effectiveTTL + 60)
}
})
}
await setPipeline.exec()
}
async clear({
key,
tags,
options,
}: {
key?: string
tags?: string[]
options?: {
autoInvalidate?: boolean
}
}): Promise<void> {
if (key) {
const keyName = this.#getKeyName(key)
const tagsKey = this.#getTagsKey(key)
const clearPipeline = this.redisClient.pipeline()
// Get tags for cleanup and delete main key in same pipeline
clearPipeline.getBuffer(tagsKey)
clearPipeline.unlink(keyName)
const results = await clearPipeline.exec()
const tagsBuffer = results?.[0]?.[1] as Buffer
if (tagsBuffer?.length) {
try {
// Binary format: array of 32-bit integers
const tagIds: number[] = []
for (let i = 0; i < tagsBuffer.length; i += 4) {
tagIds.push(tagsBuffer.readUInt32LE(i))
}
if (tagIds.length) {
const entryTags = await this.#resolveTagIds(tagIds)
const tagCleanupPipeline = this.redisClient.pipeline()
entryTags.forEach((tag) => {
const tagKey = this.#getTagKey(tag, { isHashed: true })
tagCleanupPipeline.srem(tagKey, keyName)
})
tagCleanupPipeline.unlink(tagsKey)
await tagCleanupPipeline.exec()
// Decrement reference counts and cleanup unused tags
await this.#decrementTagRefs(tagIds)
}
} catch (e) {
// noop - corrupted tag data, skip cleanup
}
}
return
}
if (tags?.length) {
// Handle wildcard tag to clear all cache data
if (tags.includes("*")) {
await this.flush()
return
}
// Get all keys associated with the tags
const pipeline = this.redisClient.pipeline()
tags.forEach((tag) => {
const tagKey = this.#getTagKey(tag)
pipeline.smembers(tagKey)
})
const tagResults = await pipeline.exec()
const allKeys = new Set<string>()
tagResults?.forEach((result) => {
if (result && result[1]) {
;(result[1] as string[]).forEach((key) => allKeys.add(key))
}
})
if (allKeys.size) {
// If no options provided (user explicit call), clear everything
if (!options) {
const deletePipeline = this.redisClient.pipeline()
// Delete main keys and options
Array.from(allKeys).forEach((key) => {
deletePipeline.unlink(key)
})
// Clean up tag references for each key
const tagDataPromises = Array.from(allKeys).map(async (key) => {
const keyWithoutPrefix = key.replace(this.keyNamePrefix, "")
const tagsKey = this.#getTagsKey(keyWithoutPrefix)
const tagsData = await this.redisClient.getBuffer(tagsKey)
return { key, tagsKey, tagsData }
})
const tagResults = await Promise.all(tagDataPromises)
// Build single pipeline for all tag cleanup operations
const tagCleanupPipeline = this.redisClient.pipeline()
const cleanupPromises = tagResults.map(
async ({ key, tagsKey, tagsData }) => {
if (tagsData) {
try {
// Binary format: array of 32-bit integers
const tagIds: number[] = []
for (let i = 0; i < tagsData.length; i += 4) {
tagIds.push(tagsData.readUInt32LE(i))
}
if (tagIds.length) {
const entryTags = await this.#resolveTagIds(tagIds)
entryTags.forEach((tag) => {
const tagKey = this.#getTagKey(tag, { isHashed: true })
tagCleanupPipeline.srem(tagKey, key)
})
tagCleanupPipeline.unlink(tagsKey)
// Decrement reference counts and cleanup unused tags
await this.#decrementTagRefs(tagIds)
}
} catch (e) {
// noop
}
}
}
)
await Promise.all(cleanupPromises)
await tagCleanupPipeline.exec()
await deletePipeline.exec()
// Clean up empty tag sets
const allTagKeys = await this.redisClient.keys(
`${this.keyNamePrefix}tag:*`
)
if (allTagKeys.length) {
const cardinalityPipeline = this.redisClient.pipeline()
allTagKeys.forEach((tagKey) => {
cardinalityPipeline.scard(tagKey)
})
const cardinalityResults = await cardinalityPipeline.exec()
// Delete empty tag keys
const emptyTagPipeline = this.redisClient.pipeline()
cardinalityResults?.forEach((result, index) => {
if (result && result[1] === 0) {
emptyTagPipeline.unlink(allTagKeys[index])
}
})
await emptyTagPipeline.exec()
}
return
}
// If autoInvalidate is true (strategy call), only clear entries with autoInvalidate=true (default)
if (options.autoInvalidate === true) {
const optionsPipeline = this.redisClient.pipeline()
Array.from(allKeys).forEach((key) => {
optionsPipeline.hget(key, "options")
})
const optionsResults = await optionsPipeline.exec()
const keysToDelete: string[] = []
Array.from(allKeys).forEach((key, index) => {
const optionsResult = optionsResults?.[index]
if (optionsResult && optionsResult[1]) {
try {
const entryOptions = JSON.parse(optionsResult[1] as string)
// Delete if entry has autoInvalidate=true or no setting (default true)
const shouldAutoInvalidate = entryOptions.autoInvalidate ?? true
if (shouldAutoInvalidate) {
keysToDelete.push(key)
}
} catch (e) {
// If can't parse options, assume it's safe to delete (default true)
keysToDelete.push(key)
}
} else {
// No options stored, default to true
keysToDelete.push(key)
}
})
if (keysToDelete.length) {
const deletePipeline = this.redisClient.pipeline()
keysToDelete.forEach((key) => {
deletePipeline.unlink(key)
})
// Clean up tag references for each key to delete
const tagDataPromises = keysToDelete.map(async (key) => {
const keyWithoutPrefix = key.replace(this.keyNamePrefix, "")
const tagsKey = this.#getTagsKey(keyWithoutPrefix)
const tagsData = await this.redisClient.getBuffer(tagsKey)
return { key, tagsKey, tagsData }
})
// Wait for all tag data fetches
const tagResults = await Promise.all(tagDataPromises)
// Build single pipeline for all tag cleanup operations
const tagCleanupPipeline = this.redisClient.pipeline()
const cleanupPromises = tagResults.map(
async ({ key, tagsKey, tagsData }) => {
if (tagsData) {
try {
// Binary format: array of 32-bit integers
const tagIds: number[] = []
for (let i = 0; i < tagsData.length; i += 4) {
tagIds.push(tagsData.readUInt32LE(i))
}
if (tagIds.length) {
const entryTags = await this.#resolveTagIds(tagIds)
entryTags.forEach((tag) => {
const tagKey = this.#getTagKey(tag, { isHashed: true })
tagCleanupPipeline.srem(tagKey, key)
})
tagCleanupPipeline.unlink(tagsKey) // Delete the tags key
// Decrement reference counts and cleanup unused tags
await this.#decrementTagRefs(tagIds)
}
} catch (e) {
// noop
}
}
}
)
await Promise.all(cleanupPromises)
await tagCleanupPipeline.exec()
await deletePipeline.exec()
// Clean up empty tag sets
const allTagKeys = await this.redisClient.keys(
`${this.keyNamePrefix}tag:*`
)
if (allTagKeys.length) {
const cleanupPipeline = this.redisClient.pipeline()
allTagKeys.forEach((tagKey) => {
cleanupPipeline.scard(tagKey)
})
const cardinalityResults = await cleanupPipeline.exec()
// Delete tag keys that are now empty
const emptyTagDeletePipeline = this.redisClient.pipeline()
cardinalityResults?.forEach((result, index) => {
if (result && result[1] === 0) {
emptyTagDeletePipeline.unlink(allTagKeys[index])
}
})
await emptyTagDeletePipeline.exec()
}
return
}
}
}
}
}
async flush(): Promise<void> {
// Use SCAN to find ALL keys with our prefix and delete them
// This includes main cache keys, tag keys (tag:*), and tags keys (tags:*)
const pattern = `${this.keyNamePrefix}*`
let cursor = "0"
do {
const result = await this.redisClient.scan(
cursor,
"MATCH",
pattern,
"COUNT",
1000
)
cursor = result[0]
const keys = result[1]
if (keys.length) {
await this.redisClient.unlink(...keys)
}
} while (cursor !== "0")
}
}
@@ -0,0 +1,26 @@
export interface RedisCacheModuleOptions {
/**
* TTL in milliseconds
*/
ttl?: number
/**
* Connection timeout in milliseconds
*/
connectTimeout?: number
/**
* Lazyload connections
*/
lazyConnect?: boolean
/**
* Connection retries
*/
retryDelayOnFailover?: number
/**
* Key prefix for all cache keys
*/
prefix?: string
/**
* Minimum size in bytes to compress data (default: 1024)
*/
compressionThreshold?: number
}
@@ -0,0 +1,12 @@
{
"extends": "../../../../_tsconfig.base.json",
"compilerOptions": {
"paths": {
"@models": ["./src/models"],
"@services": ["./src/services"],
"@repositories": ["./src/repositories"],
"@types": ["./src/types"],
"@utils": ["./src/utils"]
}
}
}