diff --git a/.changeset/two-cherries-enjoy.md b/.changeset/two-cherries-enjoy.md new file mode 100644 index 0000000000..a36b0e2a81 --- /dev/null +++ b/.changeset/two-cherries-enjoy.md @@ -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 diff --git a/integration-tests/api/medusa-config.js b/integration-tests/api/medusa-config.js index ef42b43153..ee3dc47c9c 100644 --- a/integration-tests/api/medusa-config.js +++ b/integration-tests/api/medusa-config.js @@ -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 }, + }, + }, } diff --git a/integration-tests/api/package.json b/integration-tests/api/package.json index 569e097737..ac6b86543d 100644 --- a/integration-tests/api/package.json +++ b/integration-tests/api/package.json @@ -9,6 +9,7 @@ "build": "babel src -d dist --extensions \".ts,.js\"" }, "dependencies": { + "@medusajs/cache-inmemory": "*", "@medusajs/medusa": "*", "faker": "^5.5.3", "medusa-interfaces": "*", diff --git a/integration-tests/helpers/setup-server.js b/integration-tests/helpers/setup-server.js index d2e3c24dbd..94bebd5323 100644 --- a/integration-tests/helpers/setup-server.js +++ b/integration-tests/helpers/setup-server.js @@ -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 diff --git a/integration-tests/helpers/use-redis.ts b/integration-tests/helpers/use-redis.ts deleted file mode 100644 index d098e3ba20..0000000000 --- a/integration-tests/helpers/use-redis.ts +++ /dev/null @@ -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 - }, -} diff --git a/integration-tests/plugins/medusa-config.js b/integration-tests/plugins/medusa-config.js index 30192d0c20..d938550ca4 100644 --- a/integration-tests/plugins/medusa-config.js +++ b/integration-tests/plugins/medusa-config.js @@ -39,5 +39,9 @@ module.exports = { resources: "shared", resolve: "@medusajs/inventory", }, + cacheService: { + resolve: "@medusajs/cache-inmemory", + options: { ttl: 5 }, + }, }, } diff --git a/integration-tests/plugins/package.json b/integration-tests/plugins/package.json index 320e2a4369..047169b8b3 100644 --- a/integration-tests/plugins/package.json +++ b/integration-tests/plugins/package.json @@ -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": "*", diff --git a/packages/cache-inmemory/.gitignore b/packages/cache-inmemory/.gitignore new file mode 100644 index 0000000000..874c6c69d3 --- /dev/null +++ b/packages/cache-inmemory/.gitignore @@ -0,0 +1,6 @@ +/dist +node_modules +.DS_store +.env* +.env +*.sql diff --git a/packages/cache-inmemory/README.md b/packages/cache-inmemory/README.md new file mode 100644 index 0000000000..5a6f17d30d --- /dev/null +++ b/packages/cache-inmemory/README.md @@ -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) diff --git a/packages/cache-inmemory/jest.config.js b/packages/cache-inmemory/jest.config.js new file mode 100644 index 0000000000..7de5bf104a --- /dev/null +++ b/packages/cache-inmemory/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + globals: { + "ts-jest": { + tsConfig: "tsconfig.json", + isolatedModules: false, + }, + }, + transform: { + "^.+\\.[jt]s?$": "ts-jest", + }, + testEnvironment: `node`, + moduleFileExtensions: [`js`, `ts`], +} diff --git a/packages/cache-inmemory/package.json b/packages/cache-inmemory/package.json new file mode 100644 index 0000000000..78fd914900 --- /dev/null +++ b/packages/cache-inmemory/package.json @@ -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" + } +} diff --git a/packages/cache-inmemory/src/index.ts b/packages/cache-inmemory/src/index.ts new file mode 100644 index 0000000000..5821a885e8 --- /dev/null +++ b/packages/cache-inmemory/src/index.ts @@ -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 diff --git a/packages/cache-inmemory/src/services/__tests__/inmemory-cache.js b/packages/cache-inmemory/src/services/__tests__/inmemory-cache.js new file mode 100644 index 0000000000..eb49b67833 --- /dev/null +++ b/packages/cache-inmemory/src/services/__tests__/inmemory-cache.js @@ -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) + }) +}) diff --git a/packages/cache-inmemory/src/services/index.ts b/packages/cache-inmemory/src/services/index.ts new file mode 100644 index 0000000000..bb1a625bb4 --- /dev/null +++ b/packages/cache-inmemory/src/services/index.ts @@ -0,0 +1 @@ +export { default as InMemoryCacheService } from "./inmemory-cache" diff --git a/packages/cache-inmemory/src/services/inmemory-cache.ts b/packages/cache-inmemory/src/services/inmemory-cache.ts new file mode 100644 index 0000000000..28ee9803b6 --- /dev/null +++ b/packages/cache-inmemory/src/services/inmemory-cache.ts @@ -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>() + protected readonly timoutRefs = new Map() + + constructor( + deps: InjectedDependencies, + options: InMemoryCacheModuleOptions = {} + ) { + this.TTL = options.ttl ?? DEFAULT_TTL + } + + /** + * Retrieve data from the cache. + * @param key - cache key + */ + async get(key: string): Promise { + const now = Date.now() + const record: CacheRecord | 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(key: string, data: T, ttl: number = this.TTL): Promise { + const record: CacheRecord = { 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 { + 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 diff --git a/packages/cache-inmemory/src/types/index.ts b/packages/cache-inmemory/src/types/index.ts new file mode 100644 index 0000000000..1a06bcf52b --- /dev/null +++ b/packages/cache-inmemory/src/types/index.ts @@ -0,0 +1,17 @@ +/** + * Shape of a record saved in `in-memory` cache + */ +export type CacheRecord = { + data: T + /** + * Timestamp in milliseconds + */ + expire: number +} + +export type InMemoryCacheModuleOptions = { + /** + * Time to keep data in cache (in seconds) + */ + ttl?: number +} diff --git a/packages/cache-inmemory/tsconfig.json b/packages/cache-inmemory/tsconfig.json new file mode 100644 index 0000000000..8a837a620b --- /dev/null +++ b/packages/cache-inmemory/tsconfig.json @@ -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" + ] +} diff --git a/packages/cache-redis/.gitignore b/packages/cache-redis/.gitignore new file mode 100644 index 0000000000..874c6c69d3 --- /dev/null +++ b/packages/cache-redis/.gitignore @@ -0,0 +1,6 @@ +/dist +node_modules +.DS_store +.env* +.env +*.sql diff --git a/packages/cache-redis/README.md b/packages/cache-redis/README.md new file mode 100644 index 0000000000..9c84a7163b --- /dev/null +++ b/packages/cache-redis/README.md @@ -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) diff --git a/packages/cache-redis/jest.config.js b/packages/cache-redis/jest.config.js new file mode 100644 index 0000000000..7de5bf104a --- /dev/null +++ b/packages/cache-redis/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + globals: { + "ts-jest": { + tsConfig: "tsconfig.json", + isolatedModules: false, + }, + }, + transform: { + "^.+\\.[jt]s?$": "ts-jest", + }, + testEnvironment: `node`, + moduleFileExtensions: [`js`, `ts`], +} diff --git a/packages/cache-redis/package.json b/packages/cache-redis/package.json new file mode 100644 index 0000000000..c9ec86d798 --- /dev/null +++ b/packages/cache-redis/package.json @@ -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" + } +} diff --git a/packages/cache-redis/src/index.ts b/packages/cache-redis/src/index.ts new file mode 100644 index 0000000000..c9d72fb693 --- /dev/null +++ b/packages/cache-redis/src/index.ts @@ -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 diff --git a/packages/cache-redis/src/loaders/index.ts b/packages/cache-redis/src/loaders/index.ts new file mode 100644 index 0000000000..865c427ab6 --- /dev/null +++ b/packages/cache-redis/src/loaders/index.ts @@ -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 => { + 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), + }) +} diff --git a/packages/cache-redis/src/services/__tests__/redis-cache.js b/packages/cache-redis/src/services/__tests__/redis-cache.js new file mode 100644 index 0000000000..2676edb226 --- /dev/null +++ b/packages/cache-redis/src/services/__tests__/redis-cache.js @@ -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() + }) +}) diff --git a/packages/cache-redis/src/services/index.ts b/packages/cache-redis/src/services/index.ts new file mode 100644 index 0000000000..b0c8a63971 --- /dev/null +++ b/packages/cache-redis/src/services/index.ts @@ -0,0 +1 @@ +export { default as RedisCacheService } from "./redis-cache" diff --git a/packages/cache-redis/src/services/redis-cache.ts b/packages/cache-redis/src/services/redis-cache.ts new file mode 100644 index 0000000000..665cc0b230 --- /dev/null +++ b/packages/cache-redis/src/services/redis-cache.ts @@ -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, + ttl: number = this.TTL + ): Promise { + 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(cacheKey: string): Promise { + 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 { + 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 diff --git a/packages/cache-redis/src/types/index.ts b/packages/cache-redis/src/types/index.ts new file mode 100644 index 0000000000..16662bd692 --- /dev/null +++ b/packages/cache-redis/src/types/index.ts @@ -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 +} diff --git a/packages/cache-redis/tsconfig.json b/packages/cache-redis/tsconfig.json new file mode 100644 index 0000000000..8a837a620b --- /dev/null +++ b/packages/cache-redis/tsconfig.json @@ -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" + ] +} diff --git a/packages/medusa-plugin-contentful/src/services/__tests__/contentful.js b/packages/medusa-plugin-contentful/src/services/__tests__/contentful.js index 1d0fd3f3cf..6738754564 100644 --- a/packages/medusa-plugin-contentful/src/services/__tests__/contentful.js +++ b/packages/medusa-plugin-contentful/src/services/__tests__/contentful.js @@ -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, }, diff --git a/packages/medusa-plugin-contentful/src/services/contentful.js b/packages/medusa-plugin-contentful/src/services/contentful.js index a6ea1ea4a1..6290f03ba6 100644 --- a/packages/medusa-plugin-contentful/src/services/contentful.js +++ b/packages/medusa-plugin-contentful/src/services/contentful.js @@ -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_() { diff --git a/packages/medusa-source-shopify/src/services/__mocks__/shopify-redis.js b/packages/medusa-source-shopify/src/services/__mocks__/shopify-cache.js similarity index 92% rename from packages/medusa-source-shopify/src/services/__mocks__/shopify-redis.js rename to packages/medusa-source-shopify/src/services/__mocks__/shopify-cache.js index 49aa6967c2..778cd78c2c 100644 --- a/packages/medusa-source-shopify/src/services/__mocks__/shopify-redis.js +++ b/packages/medusa-source-shopify/src/services/__mocks__/shopify-cache.js @@ -1,4 +1,4 @@ -export const ShopifyRedisServiceMock = { +export const ShopifyCacheServiceMock = { addIgnore: jest.fn().mockImplementation((_id, _event) => { return Promise.resolve() }), diff --git a/packages/medusa-source-shopify/src/services/__tests__/shopify-product.js b/packages/medusa-source-shopify/src/services/__tests__/shopify-product.js index df02ec4565..19113b1a6e 100644 --- a/packages/medusa-source-shopify/src/services/__tests__/shopify-product.js +++ b/packages/medusa-source-shopify/src/services/__tests__/shopify-product.js @@ -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, }) diff --git a/packages/medusa-source-shopify/src/services/shopify-redis.js b/packages/medusa-source-shopify/src/services/shopify-cache.js similarity index 59% rename from packages/medusa-source-shopify/src/services/shopify-redis.js rename to packages/medusa-source-shopify/src/services/shopify-cache.js index 0f7ef55da2..7c785b950e 100644 --- a/packages/medusa-source-shopify/src/services/shopify-redis.js +++ b/packages/medusa-source-shopify/src/services/shopify-cache.js @@ -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 diff --git a/packages/medusa-source-shopify/src/services/shopify-client.js b/packages/medusa-source-shopify/src/services/shopify-client.js index 945ffe9b13..35ebfe249d 100644 --- a/packages/medusa-source-shopify/src/services/shopify-client.js +++ b/packages/medusa-source-shopify/src/services/shopify-client.js @@ -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" diff --git a/packages/medusa-source-shopify/src/services/shopify-product.js b/packages/medusa-source-shopify/src/services/shopify-product.js index 3c969dc8bc..2971d79a6e 100644 --- a/packages/medusa-source-shopify/src/services/shopify-product.js +++ b/packages/medusa-source-shopify/src/services/shopify-product.js @@ -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 } diff --git a/packages/medusa/src/services/cache.ts b/packages/medusa/src/services/cache.ts deleted file mode 100644 index e9a42912b8..0000000000 --- a/packages/medusa/src/services/cache.ts +++ /dev/null @@ -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, - ttl: number = DEFAULT_CACHE_TIME - ): Promise { - 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(cacheKey: string): Promise { - 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 { - const keys = await this.redis_.keys(key) - const pipeline = this.redis_.pipeline() - - keys.forEach(function (key) { - pipeline.del(key) - }) - - await pipeline.exec() - } -} diff --git a/packages/medusa/src/services/index.ts b/packages/medusa/src/services/index.ts index 2af4f1c3da..192d151ca4 100644 --- a/packages/medusa/src/services/index.ts +++ b/packages/medusa/src/services/index.ts @@ -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" diff --git a/packages/medusa/src/services/pricing.ts b/packages/medusa/src/services/pricing.ts index 08269de96e..d52169d334 100644 --- a/packages/medusa/src/services/pricing.ts +++ b/packages/medusa/src/services/pricing.ts @@ -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 diff --git a/packages/medusa/src/services/product-variant-inventory.ts b/packages/medusa/src/services/product-variant-inventory.ts index aba455e66f..44e3438f4b 100644 --- a/packages/medusa/src/services/product-variant-inventory.ts +++ b/packages/medusa/src/services/product-variant-inventory.ts @@ -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, diff --git a/packages/medusa/src/subscribers/pricing.ts b/packages/medusa/src/subscribers/pricing.ts index 94dc4aebc0..7079c52d13 100644 --- a/packages/medusa/src/subscribers/pricing.ts +++ b/packages/medusa/src/subscribers/pricing.ts @@ -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}:*`) } } ) diff --git a/packages/modules-sdk/src/definitions.ts b/packages/modules-sdk/src/definitions.ts index b9c848bc1c..b583e44657 100644 --- a/packages/modules-sdk/src/definitions.ts +++ b/packages/modules-sdk/src/definitions.ts @@ -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 diff --git a/yarn.lock b/yarn.lock index dcd0983a61..e35e4a2b5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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