feat(medusa): Cache modules (#3187)

This commit is contained in:
Frane Polić
2023-03-10 15:09:26 +01:00
committed by GitHub
parent f43f03badb
commit f97b3d7cce
42 changed files with 783 additions and 186 deletions

View File

@@ -0,0 +1,9 @@
---
"@medusajs/cache-inmemory": minor
"@medusajs/cache-redis": minor
"@medusajs/medusa": minor
"medusa-plugin-contentful": patch
"medusa-source-shopify": patch
---
feat(medusa, cache-redis, cache-inmemory): Added cache modules

View File

@@ -3,13 +3,25 @@ const DB_USERNAME = process.env.DB_USERNAME
const DB_PASSWORD = process.env.DB_PASSWORD
const DB_NAME = process.env.DB_TEMP_NAME
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379"
module.exports = {
plugins: [],
projectConfig: {
redis_url: process.env.REDIS_URL,
redis_url: redisUrl,
database_url: `postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}/${DB_NAME}`,
database_type: "postgres",
jwt_secret: "test",
cookie_secret: "test",
},
modules: {
cacheService: {
resolve: "@medusajs/cache-inmemory",
// don't set cache since this is shared between tests
// and since we have "test-product" / "test-variant" as ids
// in a bunch of tests, this could cause that incorrect data is returned
// (e.g. price selection caches calculations under `ps:${variantId}`)
options: { ttl: 0 },
},
},
}

View File

@@ -9,6 +9,7 @@
"build": "babel src -d dist --extensions \".ts,.js\""
},
"dependencies": {
"@medusajs/cache-inmemory": "*",
"@medusajs/medusa": "*",
"faker": "^5.5.3",
"medusa-interfaces": "*",

View File

@@ -23,7 +23,6 @@ module.exports = ({ cwd, redisUrl, uploadDir, verbose, env }) => {
COOKIE_SECRET: "test",
REDIS_URL: redisUrl ? redisUrlWithDatabase : undefined, // If provided, will use a real instance, otherwise a fake instance
UPLOAD_DIR: uploadDir, // If provided, will be used for the fake local file service
CACHE_TTL: 0, // By default the cache service is disabled and 0 means that none of the cache key/value will be stored.
...env,
},
stdio: verbose

View File

@@ -1,58 +0,0 @@
const path = require("path")
const Redis = require("ioredis")
const { GenericContainer } = require("testcontainers")
require("dotenv").config({ path: path.join(__dirname, "../.env") })
const workerId = parseInt(process.env.JEST_WORKER_ID || "1")
const DB_USERNAME = process.env.DB_USERNAME || "postgres"
const DB_PASSWORD = process.env.DB_PASSWORD || ""
const DbTestUtil = {
db_: null,
setDb: function (connection) {
this.db_ = connection
},
clear: async function () {
/* noop */
},
teardown: async function () {
/* noop */
},
shutdown: async function () {
/* noop */
// TODO: stop container
},
}
const instance = DbTestUtil
module.exports = {
initRedis: async function ({ cwd }) {
// const configPath = path.resolve(path.join(cwd, `medusa-config.js`))
// const { projectConfig } = require(configPath)
const container = await new GenericContainer("redis")
.withExposedPorts(6379)
.start()
const redisClient = new Redis({
host: container.getHost(),
port: container.getMappedPort(6379),
db: workerId,
})
instance.setDb(redisClient)
return redisClient
},
useRedis: function () {
return instance
},
}

View File

@@ -39,5 +39,9 @@ module.exports = {
resources: "shared",
resolve: "@medusajs/inventory",
},
cacheService: {
resolve: "@medusajs/cache-inmemory",
options: { ttl: 5 },
},
},
}

View File

@@ -9,6 +9,7 @@
"build": "babel src -d dist --extensions \".ts,.js\""
},
"dependencies": {
"@medusajs/cache-inmemory": "*",
"@medusajs/medusa": "*",
"faker": "^5.5.3",
"medusa-fulfillment-webshipper": "*",

6
packages/cache-inmemory/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/dist
node_modules
.DS_store
.env*
.env
*.sql

View File

@@ -0,0 +1,23 @@
# Medusa Cache In-memory
Medusa in-memory cache module. Use plain JS Map as a cache store.
## Installation
```
yarn add @medusajs/cache-inmemory
```
## Options
```
{
ttl?: number // Time to keep data in cache (in seconds)
}
```
### Note
Recommended for testing and development. For production, use Redis cache module.
### Other caching modules
- [Medusa Cache Redis](../cache-redis/README.md)

View File

@@ -0,0 +1,13 @@
module.exports = {
globals: {
"ts-jest": {
tsConfig: "tsconfig.json",
isolatedModules: false,
},
},
transform: {
"^.+\\.[jt]s?$": "ts-jest",
},
testEnvironment: `node`,
moduleFileExtensions: [`js`, `ts`],
}

View File

@@ -0,0 +1,36 @@
{
"name": "@medusajs/cache-inmemory",
"version": "1.0.0",
"description": "In-memory Cache Module for Medusa",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/cache-inmemory"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"author": "Medusa",
"license": "MIT",
"devDependencies": {
"@medusajs/medusa": "*",
"cross-env": "^5.2.1",
"jest": "^25.5.4",
"ts-jest": "^25.5.1",
"typescript": "^4.4.4"
},
"scripts": {
"watch": "tsc --build --watch",
"prepare": "cross-env NODE_ENV=production yarn run build",
"build": "tsc --build",
"test": "jest --passWithNoTests",
"test:unit": "jest --passWithNoTests"
},
"peerDependencies": {
"@medusajs/medusa": "^1.7.11"
}
}

View File

@@ -0,0 +1,13 @@
import { ModuleExports } from "@medusajs/modules-sdk"
import InMemoryCacheService from "./services/inmemory-cache"
const loaders = []
const service = InMemoryCacheService
const moduleDefinition: ModuleExports = {
service,
loaders,
}
export default moduleDefinition

View File

@@ -0,0 +1,96 @@
import { InMemoryCacheService } from "../index"
jest.setTimeout(40000)
describe("InMemoryCacheService", () => {
let inMemoryCache
beforeEach(() => {
jest.clearAllMocks()
})
it("Stores and retrieves data", async () => {
inMemoryCache = new InMemoryCacheService({}, {})
await inMemoryCache.set("cache-key", { data: "value" })
expect(await inMemoryCache.get("cache-key")).toEqual({ data: "value" })
})
it("Invalidates single record", async () => {
inMemoryCache = new InMemoryCacheService({}, {})
await inMemoryCache.set("cache-key", { data: "value" })
await inMemoryCache.invalidate("cache-key")
expect(await inMemoryCache.get("cache-key")).toEqual(null)
})
it("Invalidates multiple keys with wildcard (end matching)", async () => {
inMemoryCache = new InMemoryCacheService({}, {})
await inMemoryCache.set("cache-key:id_1:x:y", { data: "value" })
await inMemoryCache.set("cache-key:id_2:x:y", { data: "value" })
await inMemoryCache.set("cache-key:id_3:x:y", { data: "value" })
await inMemoryCache.set("cache-key-old", { data: "value" })
await inMemoryCache.invalidate("cache-key:*")
expect(await inMemoryCache.get("cache-key:id1:x:y")).toEqual(null)
expect(await inMemoryCache.get("cache-key:id2:x:y")).toEqual(null)
expect(await inMemoryCache.get("cache-key:id3:x:y")).toEqual(null)
expect(await inMemoryCache.get("cache-key-old")).toEqual({ data: "value" })
})
it("Invalidates multiple keys with wildcard (middle matching)", async () => {
inMemoryCache = new InMemoryCacheService({}, {})
await inMemoryCache.set("cache-key:1:new", { data: "value" })
await inMemoryCache.set("cache-key:2:new", { data: "value" })
await inMemoryCache.set("cache-key:3:new", { data: "value" })
await inMemoryCache.set("cache-key:4:old", { data: "value" })
await inMemoryCache.invalidate("cache-key:*:new")
expect(await inMemoryCache.get("cache-key:1:new")).toEqual(null)
expect(await inMemoryCache.get("cache-key:2:new")).toEqual(null)
expect(await inMemoryCache.get("cache-key:3:new")).toEqual(null)
expect(await inMemoryCache.get("cache-key:4:old")).toEqual({
data: "value",
})
})
it("Removes data after TTL", async () => {
inMemoryCache = new InMemoryCacheService({}, {})
await inMemoryCache.set("cache-key", { data: "value" }, 2)
expect(await inMemoryCache.get("cache-key")).toEqual({ data: "value" })
await new Promise((res) => setTimeout(res, 3000))
expect(await inMemoryCache.get("cache-key")).toEqual(null)
})
it("Removes data after default TTL if TTL params isn't passed", async () => {
inMemoryCache = new InMemoryCacheService({})
await inMemoryCache.set("cache-key", { data: "value" })
expect(await inMemoryCache.get("cache-key")).toEqual({ data: "value" })
await new Promise((res) => setTimeout(res, 33000))
expect(await inMemoryCache.get("cache-key")).toEqual(null)
})
it("Removes data after TTL from the config if TTL params isn't passed", async () => {
inMemoryCache = new InMemoryCacheService({}, { ttl: 1 })
await inMemoryCache.set("cache-key", { data: "value" })
expect(await inMemoryCache.get("cache-key")).toEqual({ data: "value" })
await new Promise((res) => setTimeout(res, 2000))
expect(await inMemoryCache.get("cache-key")).toEqual(null)
})
})

View File

@@ -0,0 +1 @@
export { default as InMemoryCacheService } from "./inmemory-cache"

View File

@@ -0,0 +1,101 @@
import { ICacheService } from "@medusajs/medusa"
import { CacheRecord, InMemoryCacheModuleOptions } from "../types"
const DEFAULT_TTL = 30 // seconds
type InjectedDependencies = {}
/**
* Class represents basic, in-memory, cache store.
*/
class InMemoryCacheService implements ICacheService {
protected readonly TTL: number
protected readonly store = new Map<string, CacheRecord<any>>()
protected readonly timoutRefs = new Map<string, NodeJS.Timeout>()
constructor(
deps: InjectedDependencies,
options: InMemoryCacheModuleOptions = {}
) {
this.TTL = options.ttl ?? DEFAULT_TTL
}
/**
* Retrieve data from the cache.
* @param key - cache key
*/
async get<T>(key: string): Promise<T | null> {
const now = Date.now()
const record: CacheRecord<T> | undefined = this.store.get(key)
const recordExpire = record?.expire ?? Infinity
if (!record || recordExpire < now) {
return null
}
return record.data
}
/**
* Set data to the cache.
* @param key - cache key under which the data is stored
* @param data - data to be stored in the cache
* @param ttl - expiration time in seconds
*/
async set<T>(key: string, data: T, ttl: number = this.TTL): Promise<void> {
const record: CacheRecord<T> = { data, expire: ttl * 1000 + Date.now() }
const oldRecord = this.store.get(key)
if (oldRecord) {
clearTimeout(this.timoutRefs.get(key))
this.timoutRefs.delete(key)
}
const ref = setTimeout(() => {
this.invalidate(key)
}, ttl * 1000)
this.timoutRefs.set(key, ref)
this.store.set(key, record)
}
/**
* Delete data from the cache.
* Could use wildcard (*) matcher e.g. `invalidate("ps:*")` to delete all keys that start with "ps:"
*
* @param key - cache key
*/
async invalidate(key: string): Promise<void> {
let keys = [key]
if (key.includes("*")) {
const regExp = new RegExp(key.replace("*", ".*"))
keys = Array.from(this.store.keys()).filter((k) => k.match(regExp))
}
keys.forEach((key) => {
const timeoutRef = this.timoutRefs.get(key)
if (timeoutRef) {
clearTimeout(timeoutRef)
this.timoutRefs.delete(key)
}
this.store.delete(key)
})
}
/**
* Delete the entire cache.
*/
async clear() {
this.timoutRefs.forEach((ref) => clearTimeout(ref))
this.timoutRefs.clear()
this.store.clear()
}
}
export default InMemoryCacheService

View File

@@ -0,0 +1,17 @@
/**
* Shape of a record saved in `in-memory` cache
*/
export type CacheRecord<T> = {
data: T
/**
* Timestamp in milliseconds
*/
expire: number
}
export type InMemoryCacheModuleOptions = {
/**
* Time to keep data in cache (in seconds)
*/
ttl?: number
}

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"lib": [
"es5",
"es6",
"es2019"
],
"target": "es5",
"outDir": "./dist",
"esModuleInterop": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"noImplicitReturns": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true // to use ES5 specific tooling
},
"include": ["src"],
"exclude": [
"dist",
"./src/**/__tests__",
"./src/**/__mocks__",
"./src/**/__fixtures__",
"node_modules"
]
}

6
packages/cache-redis/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/dist
node_modules
.DS_store
.env*
.env
*.sql

View File

@@ -0,0 +1,26 @@
# Medusa Cache Redis
Use Redis as a Medusa cache store.
## Installation
```
yarn add @medusajs/cache-redis
```
## Options
```
{
ttl?: number // Time to keep data in cache (in seconds)
redisUrl?: string // Redis instance connection string
redisOptions?: RedisOptions // Redis client options
namespace?: string // Prefix for event keys (the default is `medusa:`)
}
```
### Other caching modules
- [Medusa Cache In-Memory](../cache-inmemory/README.md)

View File

@@ -0,0 +1,13 @@
module.exports = {
globals: {
"ts-jest": {
tsConfig: "tsconfig.json",
isolatedModules: false,
},
},
transform: {
"^.+\\.[jt]s?$": "ts-jest",
},
testEnvironment: `node`,
moduleFileExtensions: [`js`, `ts`],
}

View File

@@ -0,0 +1,36 @@
{
"name": "@medusajs/cache-redis",
"version": "1.0.0",
"description": "Redis Cache Module for Medusa",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/cache-redis"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"author": "Medusa",
"license": "MIT",
"devDependencies": {
"@medusajs/medusa": "*",
"cross-env": "^5.2.1",
"jest": "^25.5.4",
"ts-jest": "^25.5.1",
"typescript": "^4.4.4"
},
"scripts": {
"watch": "tsc --build --watch",
"prepare": "cross-env NODE_ENV=production yarn run build",
"build": "tsc --build",
"test": "jest --passWithNoTests",
"test:unit": "jest --passWithNoTests"
},
"peerDependencies": {
"@medusajs/medusa": "^1.7.11"
}
}

View File

@@ -0,0 +1,14 @@
import { ModuleExports } from "@medusajs/modules-sdk"
import { RedisCacheService } from "./services"
import Loader from "./loaders"
const service = RedisCacheService
const loaders = [Loader]
const moduleDefinition: ModuleExports = {
service,
loaders,
}
export default moduleDefinition

View File

@@ -0,0 +1,38 @@
import Redis from "ioredis"
import { asValue } from "awilix"
import { LoaderOptions } from "@medusajs/modules-sdk"
import { RedisCacheModuleOptions } from "../types"
export default async ({
container,
logger,
options,
}: LoaderOptions): Promise<void> => {
const { redisUrl, redisOptions } = options as RedisCacheModuleOptions
if (!redisUrl) {
throw Error(
"No `redisUrl` provided in `cacheService` module options. It is required for the Redis Cache Module."
)
}
const connection = new Redis(redisUrl, {
// Lazy connect to properly handle connection errors
lazyConnect: true,
...(redisOptions ?? {}),
})
try {
await connection.connect()
logger?.info(`Connection to Redis in module 'cache-redis' established`)
} catch (err) {
logger?.error(
`An error occurred while connecting to Redis in module 'cache-redis': ${err}`
)
}
container.register({
cacheRedisConnection: asValue(connection),
})
}

View File

@@ -0,0 +1,29 @@
import { RedisCacheService } from "../index"
const redisClientMock = {
set: jest.fn(),
get: jest.fn(),
}
describe("RedisCacheService", () => {
let cacheService
beforeEach(() => {
jest.clearAllMocks()
})
it("Underlying client methods are called", async () => {
cacheService = new RedisCacheService(
{
cacheRedisConnection: redisClientMock,
},
{}
)
await cacheService.set("test-key", "value")
expect(redisClientMock.set).toBeCalled()
await cacheService.get("test-key")
expect(redisClientMock.get).toBeCalled()
})
})

View File

@@ -0,0 +1 @@
export { default as RedisCacheService } from "./redis-cache"

View File

@@ -0,0 +1,88 @@
import { Redis } from "ioredis"
import { ICacheService } from "@medusajs/medusa"
import { RedisCacheModuleOptions } from "../types"
const DEFAULT_NAMESPACE = "medusa"
const DEFAULT_CACHE_TIME = 30 // 30 seconds
const EXPIRY_MODE = "EX" // "EX" stands for an expiry time in second
type InjectedDependencies = {
cacheRedisConnection: Redis
}
class RedisCacheService implements ICacheService {
protected readonly TTL: number
protected readonly redis: Redis
private readonly namespace: string
constructor(
{ cacheRedisConnection }: InjectedDependencies,
options: RedisCacheModuleOptions = {}
) {
this.redis = cacheRedisConnection
this.TTL = options.ttl ?? DEFAULT_CACHE_TIME
this.namespace = options.namespace || DEFAULT_NAMESPACE
}
/**
* Set a key/value pair to the cache.
* If the ttl is 0 it will act like the value should not be cached at all.
* @param key
* @param data
* @param ttl
*/
async set(
key: string,
data: Record<string, unknown>,
ttl: number = this.TTL
): Promise<void> {
await this.redis.set(
this.getCacheKey(key),
JSON.stringify(data),
EXPIRY_MODE,
ttl
)
}
/**
* Retrieve a cached value belonging to the given key.
* @param cacheKey
*/
async get<T>(cacheKey: string): Promise<T | null> {
cacheKey = this.getCacheKey(cacheKey)
try {
const cached = await this.redis.get(cacheKey)
if (cached) {
return JSON.parse(cached)
}
} catch (err) {
await this.redis.del(cacheKey)
}
return null
}
/**
* Invalidate cache for a specific key. a key can be either a specific key or more global such as "ps:*".
* @param key
*/
async invalidate(key: string): Promise<void> {
const keys = await this.redis.keys(this.getCacheKey(key))
const pipeline = this.redis.pipeline()
keys.forEach(function (key) {
pipeline.del(key)
})
await pipeline.exec()
}
/**
* Returns namespaced cache key
* @param key
*/
private getCacheKey(key: string) {
return this.namespace ? `${this.namespace}:${key}` : key
}
}
export default RedisCacheService

View File

@@ -0,0 +1,27 @@
import { RedisOptions } from "ioredis"
/**
* Module config type
*/
export type RedisCacheModuleOptions = {
/**
* Time to keep data in cache (in seconds)
*/
ttl?: number
/**
* Redis connection string
*/
redisUrl?: string
/**
* Redis client options
*/
redisOptions?: RedisOptions
/**
* Prefix for event keys
* @default `medusa:`
*/
namespace?: string
}

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"lib": [
"es5",
"es6",
"es2019"
],
"target": "es5",
"outDir": "./dist",
"esModuleInterop": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"noImplicitReturns": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true // to use ES5 specific tooling
},
"include": ["src"],
"exclude": [
"dist",
"./src/**/__tests__",
"./src/**/__mocks__",
"./src/**/__fixtures__",
"node_modules"
]
}

View File

@@ -18,7 +18,7 @@ describe("ContentfulService", () => {
return Promise.resolve(undefined)
}),
}
const redisClient = {
const cacheService = {
get: async (id) => {
// const key = `${id}_ignore_${side}`
if (id === `ignored_ignore_contentful`) {
@@ -44,7 +44,7 @@ describe("ContentfulService", () => {
{
regionService,
productService,
redisClient,
cacheService,
productVariantService,
eventBusService,
},

View File

@@ -9,7 +9,7 @@ class ContentfulService extends BaseService {
{
regionService,
productService,
redisClient,
cacheService,
productVariantService,
eventBusService,
},
@@ -31,24 +31,23 @@ class ContentfulService extends BaseService {
accessToken: options.access_token,
})
this.redis_ = redisClient
this.cacheService_ = cacheService
this.capab_ = {}
}
async addIgnore_(id, side) {
const key = `${id}_ignore_${side}`
return await this.redis_.set(
return await this.cacheService_.set(
key,
1,
"EX",
this.options_.ignore_threshold || IGNORE_THRESHOLD
)
}
async shouldIgnore_(id, side) {
const key = `${id}_ignore_${side}`
return await this.redis_.get(key)
return await this.cacheService_.get(key)
}
async getContentfulEnvironment_() {

View File

@@ -1,4 +1,4 @@
export const ShopifyRedisServiceMock = {
export const ShopifyCacheServiceMock = {
addIgnore: jest.fn().mockImplementation((_id, _event) => {
return Promise.resolve()
}),

View File

@@ -4,7 +4,7 @@ import { ProductServiceMock } from "../__mocks__/product-service"
import { ProductVariantServiceMock } from "../__mocks__/product-variant"
import { ShippingProfileServiceMock } from "../__mocks__/shipping-profile"
import { ShopifyClientServiceMock } from "../__mocks__/shopify-client"
import { ShopifyRedisServiceMock } from "../__mocks__/shopify-redis"
import { ShopifyCacheServiceMock } from "../__mocks__/shopify-cache"
import { medusaProducts, shopifyProducts } from "../__mocks__/test-products"
describe("ShopifyProductService", () => {
@@ -32,7 +32,7 @@ describe("ShopifyProductService", () => {
manager: MockManager,
shopifyClientService: ShopifyClientServiceMock,
productService: ProductServiceMock,
shopifyRedisService: ShopifyRedisServiceMock,
shopifyCacheService: ShopifyCacheServiceMock,
shippingProfileService: ShippingProfileServiceMock,
productVariantService: ProductVariantServiceMock,
})
@@ -46,8 +46,8 @@ describe("ShopifyProductService", () => {
const product = await shopifyProductService.create(data)
expect(ShopifyRedisServiceMock.shouldIgnore).toHaveBeenCalledTimes(1)
expect(ShopifyRedisServiceMock.addIgnore).toHaveBeenCalledTimes(1)
expect(ShopifyCacheServiceMock.shouldIgnore).toHaveBeenCalledTimes(1)
expect(ShopifyCacheServiceMock.addIgnore).toHaveBeenCalledTimes(1)
expect(ShippingProfileServiceMock.retrieveDefault).toHaveBeenCalledTimes(
1
)
@@ -66,7 +66,7 @@ describe("ShopifyProductService", () => {
manager: MockManager,
shopifyClientService: ShopifyClientServiceMock,
productService: ProductServiceMock,
shopifyRedisService: ShopifyRedisServiceMock,
shopifyCacheService: ShopifyCacheServiceMock,
shippingProfileService: ShippingProfileServiceMock,
productVariantService: ProductVariantServiceMock,
})

View File

@@ -1,41 +1,40 @@
// shopify-redis
import { BaseService } from "medusa-interfaces"
import { IGNORE_THRESHOLD } from "../utils/const"
class shopifyRedisService extends BaseService {
constructor({ redisClient }, options) {
class ShopifyCacheService extends BaseService {
constructor({ cacheService }, options) {
super()
this.options_ = options
/** @private @const {RedisClient} */
this.redis_ = redisClient
/** @private @const {ICacheService} */
this.cacheService_ = cacheService
}
async addIgnore(id, side) {
const key = `sh_${id}_ignore_${side}`
return await this.redis_.set(
return await this.cacheService_.set(
key,
1,
"EX",
this.options_.ignore_threshold || IGNORE_THRESHOLD
)
}
async shouldIgnore(id, action) {
const key = `sh_${id}_ignore_${action}`
return await this.redis_.get(key)
return await this.cacheService_.get(key)
}
async addUniqueValue(uniqueVal, type) {
const key = `sh_${uniqueVal}_${type}`
return await this.redis_.set(key, 1, "EX", 60 * 5)
return await this.cacheService_.set(key, 1, 60 * 5)
}
async getUniqueValue(uniqueVal, type) {
const key = `sh_${uniqueVal}_${type}`
return await this.redis_.get(key)
return await this.cacheService_.get(key)
}
}
export default shopifyRedisService
export default ShopifyCacheService

View File

@@ -1,5 +1,6 @@
import { DataType } from "@shopify/shopify-api"
import { BaseService } from "medusa-interfaces"
import { createClient } from "../utils/create-client"
import { pager } from "../utils/pager"

View File

@@ -14,7 +14,7 @@ class ShopifyProductService extends BaseService {
productVariantService,
shippingProfileService,
shopifyClientService,
shopifyRedisService,
shopifyCacheService,
},
options
) {
@@ -32,8 +32,8 @@ class ShopifyProductService extends BaseService {
this.shippingProfileService_ = shippingProfileService
/** @private @const {ShopifyRestClient} */
this.shopify_ = shopifyClientService
this.redis_ = shopifyRedisService
/** @private @const {ICacheService} */
this.cacheService_ = shopifyCacheService
}
withTransaction(transactionManager) {
@@ -41,15 +41,17 @@ class ShopifyProductService extends BaseService {
return this
}
const cloned = new ShopifyProductService({
manager: transactionManager,
options: this.options,
shippingProfileService: this.shippingProfileService_,
productVariantService: this.productVariantService_,
productService: this.productService_,
shopifyClientService: this.shopify_,
shopifyRedisService: this.redis_,
})
const cloned = new ShopifyProductService(
{
manager: transactionManager,
shippingProfileService: this.shippingProfileService_,
productVariantService: this.productVariantService_,
productService: this.productService_,
shopifyClientService: this.shopify_,
shopifyCacheService: this.cacheService_,
},
this.options
)
cloned.transactionManager_ = transactionManager
@@ -65,7 +67,10 @@ class ShopifyProductService extends BaseService {
*/
async create(data) {
return this.atomicPhase_(async (manager) => {
const ignore = await this.redis_.shouldIgnore(data.id, "product.created")
const ignore = await this.cacheService_.shouldIgnore(
data.id,
"product.created"
)
if (ignore) {
return
}
@@ -106,7 +111,7 @@ class ShopifyProductService extends BaseService {
}
}
await this.redis_.addIgnore(data.id, "product.created")
await this.cacheService_.addIgnore(data.id, "product.created")
return product
})
@@ -114,7 +119,7 @@ class ShopifyProductService extends BaseService {
async update(existing, shopifyUpdate) {
return this.atomicPhase_(async (manager) => {
const ignore = await this.redis_.shouldIgnore(
const ignore = await this.cacheService_.shouldIgnore(
shopifyUpdate.id,
"product.updated"
)
@@ -140,7 +145,7 @@ class ShopifyProductService extends BaseService {
}
if (!isEmpty(update)) {
await this.redis_.addIgnore(shopifyUpdate.id, "product.updated")
await this.cacheService_.addIgnore(shopifyUpdate.id, "product.updated")
return await this.productService_
.withTransaction(manager)
.update(existing.id, update)
@@ -219,7 +224,7 @@ class ShopifyProductService extends BaseService {
)
})
await this.redis_.addIgnore(product.external_id, "product.updated")
await this.cacheService_.addIgnore(product.external_id, "product.updated")
}
async shopifyVariantUpdate(id, fields) {
@@ -273,7 +278,7 @@ class ShopifyProductService extends BaseService {
)
})
await this.redis_.addIgnore(
await this.cacheService_.addIgnore(
variant.metadata.sh_id,
"product-variant.updated"
)
@@ -298,7 +303,10 @@ class ShopifyProductService extends BaseService {
)
})
await this.redis_.addIgnore(metadata.sh_id, "product-variant.deleted")
await this.cacheService_.addIgnore(
metadata.sh_id,
"product-variant.deleted"
)
}
async updateCollectionId(productId, collectionId) {
@@ -314,11 +322,11 @@ class ShopifyProductService extends BaseService {
const { id, variants, options } = product
for (let variant of updateVariants) {
const ignore =
(await this.redis_.shouldIgnore(
(await this.cacheService_.shouldIgnore(
variant.metadata.sh_id,
"product-variant.updated"
)) ||
(await this.redis_.shouldIgnore(
(await this.cacheService_.shouldIgnore(
variant.metadata.sh_id,
"product-variant.created"
))
@@ -349,7 +357,7 @@ class ShopifyProductService extends BaseService {
return this.atomicPhase_(async (manager) => {
const { variants } = product
for (const variant of variants) {
const ignore = await this.redis_.shouldIgnore(
const ignore = await this.cacheService_.shouldIgnore(
variant.metadata.sh_id,
"product-variant.deleted"
)
@@ -551,15 +559,15 @@ class ShopifyProductService extends BaseService {
async testUnique_(uniqueVal, type) {
// Test if the unique value has already been added, if it was then pass the value onto the duplicate handler and return the new value
const exists = await this.redis_.getUniqueValue(uniqueVal, type)
const exists = await this.cacheService_.getUniqueValue(uniqueVal, type)
if (exists) {
const dupValue = this.handleDuplicateConstraint_(uniqueVal)
await this.redis_.addUniqueValue(dupValue, type)
await this.cacheService_.addUniqueValue(dupValue, type)
return dupValue
}
// If it doesn't exist, we return the value
await this.redis_.addUniqueValue(uniqueVal, type)
await this.cacheService_.addUniqueValue(uniqueVal, type)
return uniqueVal
}

View File

@@ -1,66 +0,0 @@
import { Redis } from "ioredis"
import { ICacheService } from "../interfaces"
const DEFAULT_CACHE_TIME = 30 // 30 seconds
const EXPIRY_MODE = "EX" // "EX" stands for an expiry time in second
export default class CacheService implements ICacheService {
protected readonly redis_: Redis
constructor({ redisClient }) {
this.redis_ = redisClient
}
/**
* Set a key/value pair to the cache.
* It is also possible to manage the ttl through environment variable using CACHE_TTL. If the ttl is 0 it will
* act like the value should not be cached at all.
* @param key
* @param data
* @param ttl
*/
async set(
key: string,
data: Record<string, unknown>,
ttl: number = DEFAULT_CACHE_TIME
): Promise<void> {
ttl = Number(process.env.CACHE_TTL ?? ttl)
if (ttl === 0) {
// No need to call redis set without expiry time
return
}
await this.redis_.set(key, JSON.stringify(data), EXPIRY_MODE, ttl)
}
/**
* Retrieve a cached value belonging to the given key.
* @param cacheKey
*/
async get<T>(cacheKey: string): Promise<T | null> {
try {
const cached = await this.redis_.get(cacheKey)
if (cached) {
return JSON.parse(cached)
}
} catch (err) {
await this.redis_.del(cacheKey)
}
return null
}
/**
* Invalidate cache for a specific key. a key can be either a specific key or more global such as "ps:*".
* @param key
*/
async invalidate(key: string): Promise<void> {
const keys = await this.redis_.keys(key)
const pipeline = this.redis_.pipeline()
keys.forEach(function (key) {
pipeline.del(key)
})
await pipeline.exec()
}
}

View File

@@ -2,7 +2,6 @@ export { default as AnalyticsConfigService } from "./analytics-config"
export { default as AuthService } from "./auth"
export { default as BatchJobService } from "./batch-job"
export { default as CartService } from "./cart"
export { default as CacheService } from "./cache"
export { default as ClaimService } from "./claim"
export { default as ClaimItemService } from "./claim-item"
export { default as CurrencyService } from "./currency"

View File

@@ -57,7 +57,7 @@ class PricingService extends TransactionBaseService {
}
/**
* Collects additional information neccessary for completing the price
* Collects additional information necessary for completing the price
* selection.
* @param context - the price selection context to use
* @return The pricing context

View File

@@ -1,6 +1,7 @@
import { isDefined, MedusaError } from "medusa-core-utils"
import { EntityManager, In } from "typeorm"
import {
ICacheService,
IInventoryService,
IStockLocationService,
TransactionBaseService,
@@ -14,7 +15,6 @@ import {
} from "../types/inventory"
import { PricedProduct, PricedVariant } from "../types/pricing"
import {
CacheService,
ProductVariantService,
SalesChannelInventoryService,
SalesChannelLocationService,
@@ -35,7 +35,7 @@ class ProductVariantInventoryService extends TransactionBaseService {
protected readonly productVariantService_: ProductVariantService
protected readonly stockLocationService_: IStockLocationService
protected readonly inventoryService_: IInventoryService
protected readonly cacheService_: CacheService
protected readonly cacheService_: ICacheService
constructor({
stockLocationService,

View File

@@ -1,8 +1,5 @@
import {
CacheService,
EventBusService,
ProductVariantService,
} from "../services"
import { EventBusService, ProductVariantService } from "../services"
import { ICacheService } from "../interfaces"
type ProductVariantUpdatedEventData = {
id: string
@@ -12,7 +9,7 @@ type ProductVariantUpdatedEventData = {
class PricingSubscriber {
protected readonly eventBus_: EventBusService
protected readonly cacheService_: CacheService
protected readonly cacheService_: ICacheService
constructor({ eventBusService, cacheService }) {
this.eventBus_ = eventBusService
@@ -23,7 +20,7 @@ class PricingSubscriber {
async (data) => {
const { id, fields } = data as ProductVariantUpdatedEventData
if (fields.includes("prices")) {
await this.cacheService_.invalidate(`ps:${id}*`)
await this.cacheService_.invalidate(`ps:${id}:*`)
}
}
)

View File

@@ -25,6 +25,18 @@ export const MODULE_DEFINITIONS: ModuleDefinition[] = [
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
{
key: "cacheService",
registrationName: "cacheService",
defaultPackage: "@medusajs/cache-inmemory",
label: "CacheService",
isRequired: true,
canOverride: true,
defaultModuleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resources: MODULE_RESOURCE_TYPE.SHARED,
},
},
]
export default MODULE_DEFINITIONS

View File

@@ -5694,6 +5694,34 @@ __metadata:
languageName: unknown
linkType: soft
"@medusajs/cache-inmemory@*, @medusajs/cache-inmemory@workspace:packages/cache-inmemory":
version: 0.0.0-use.local
resolution: "@medusajs/cache-inmemory@workspace:packages/cache-inmemory"
dependencies:
"@medusajs/medusa": "*"
cross-env: ^5.2.1
jest: ^25.5.4
ts-jest: ^25.5.1
typescript: ^4.4.4
peerDependencies:
"@medusajs/medusa": ^1.7.11
languageName: unknown
linkType: soft
"@medusajs/cache-redis@workspace:packages/cache-redis":
version: 0.0.0-use.local
resolution: "@medusajs/cache-redis@workspace:packages/cache-redis"
dependencies:
"@medusajs/medusa": "*"
cross-env: ^5.2.1
jest: ^25.5.4
ts-jest: ^25.5.1
typescript: ^4.4.4
peerDependencies:
"@medusajs/medusa": ^1.7.11
languageName: unknown
linkType: soft
"@medusajs/inventory@workspace:packages/inventory":
version: 0.0.0-use.local
resolution: "@medusajs/inventory@workspace:packages/inventory"
@@ -23220,6 +23248,7 @@ __metadata:
"@babel/cli": ^7.12.10
"@babel/core": ^7.12.10
"@babel/node": ^7.12.10
"@medusajs/cache-inmemory": "*"
"@medusajs/medusa": "*"
babel-preset-medusa-package: "*"
faker: ^5.5.3
@@ -23237,6 +23266,7 @@ __metadata:
"@babel/cli": ^7.12.10
"@babel/core": ^7.12.10
"@babel/node": ^7.12.10
"@medusajs/cache-inmemory": "*"
"@medusajs/medusa": "*"
babel-preset-medusa-package: "*"
faker: ^5.5.3