Feat(): distributed caching (#13435)
RESOLVES CORE-1153 **What** - This pr mainly lay the foundation the caching layer. It comes with a modules (built in memory cache) and a redis provider. - Apply caching to few touch point to test Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
co-authored by
Carlos R. L. Rodrigues
parent
5b135a41fe
commit
b9d6f73320
@@ -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)
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user