feat(providers): locking redis (#9544)
This commit is contained in:
@@ -76,14 +76,28 @@ class RedisCacheService implements ICacheService {
|
||||
* @param key
|
||||
*/
|
||||
async invalidate(key: string): Promise<void> {
|
||||
const keys = await this.redis.keys(this.getCacheKey(key))
|
||||
const pipeline = this.redis.pipeline()
|
||||
const pattern = this.getCacheKey(key)
|
||||
let cursor = "0"
|
||||
do {
|
||||
const result = await this.redis.scan(
|
||||
cursor,
|
||||
"MATCH",
|
||||
pattern,
|
||||
"COUNT",
|
||||
100
|
||||
)
|
||||
cursor = result[0]
|
||||
const keys = result[1]
|
||||
|
||||
keys.forEach(function (key) {
|
||||
pipeline.del(key)
|
||||
})
|
||||
if (keys.length > 0) {
|
||||
const deletePipeline = this.redis.pipeline()
|
||||
for (const key of keys) {
|
||||
deletePipeline.del(key)
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
await deletePipeline.exec()
|
||||
}
|
||||
} while (cursor !== "0")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ILockingModule } from "@medusajs/framework/types"
|
||||
import { Modules } from "@medusajs/framework/utils"
|
||||
import { Modules, promiseAll } from "@medusajs/framework/utils"
|
||||
import { moduleIntegrationTestRunner } from "medusa-test-utils"
|
||||
import { setTimeout } from "node:timers/promises"
|
||||
|
||||
@@ -63,7 +63,7 @@ moduleIntegrationTestRunner<ILockingModule>({
|
||||
|
||||
expect(userReleased).toBe(false)
|
||||
await expect(anotherUserLock).rejects.toThrowError(
|
||||
`"key_name" is already locked.`
|
||||
`Failed to acquire lock for key "key_name"`
|
||||
)
|
||||
|
||||
const releasing = await service.release("key_name", {
|
||||
@@ -82,16 +82,20 @@ moduleIntegrationTestRunner<ILockingModule>({
|
||||
ownerId: "user_id_000",
|
||||
}
|
||||
|
||||
expect(service.acquire(keyToLock, user_1)).resolves.toBeUndefined()
|
||||
await expect(
|
||||
service.acquire(keyToLock, user_1)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(service.acquire(keyToLock, user_1)).resolves.toBeUndefined()
|
||||
await expect(
|
||||
service.acquire(keyToLock, user_1)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(service.acquire(keyToLock, user_2)).rejects.toThrowError(
|
||||
`"${keyToLock}" is already locked.`
|
||||
await expect(service.acquire(keyToLock, user_2)).rejects.toThrowError(
|
||||
`Failed to acquire lock for key "${keyToLock}"`
|
||||
)
|
||||
|
||||
expect(service.acquire(keyToLock, user_2)).rejects.toThrowError(
|
||||
`"${keyToLock}" is already locked.`
|
||||
await expect(service.acquire(keyToLock, user_2)).rejects.toThrowError(
|
||||
`Failed to acquire lock for key "${keyToLock}"`
|
||||
)
|
||||
|
||||
await service.acquire(keyToLock, user_1)
|
||||
@@ -104,6 +108,40 @@ moduleIntegrationTestRunner<ILockingModule>({
|
||||
const release = await service.release(keyToLock, user_1)
|
||||
expect(release).toBe(true)
|
||||
})
|
||||
|
||||
it("should fail to acquire the same key when no owner is provided", async () => {
|
||||
const keyToLock = "mySpecialKey"
|
||||
|
||||
const user_2 = {
|
||||
ownerId: "user_id_000",
|
||||
}
|
||||
|
||||
await expect(service.acquire(keyToLock)).resolves.toBeUndefined()
|
||||
|
||||
await expect(service.acquire(keyToLock)).rejects.toThrow(
|
||||
`Failed to acquire lock for key "${keyToLock}"`
|
||||
)
|
||||
|
||||
await expect(service.acquire(keyToLock)).rejects.toThrow(
|
||||
`Failed to acquire lock for key "${keyToLock}"`
|
||||
)
|
||||
|
||||
await expect(service.acquire(keyToLock, user_2)).rejects.toThrow(
|
||||
`Failed to acquire lock for key "${keyToLock}"`
|
||||
)
|
||||
|
||||
await expect(service.acquire(keyToLock, user_2)).rejects.toThrow(
|
||||
`Failed to acquire lock for key "${keyToLock}"`
|
||||
)
|
||||
|
||||
const releaseNotLocked = await service.release(keyToLock, {
|
||||
ownerId: "user_id_000",
|
||||
})
|
||||
expect(releaseNotLocked).toBe(false)
|
||||
|
||||
const release = await service.release(keyToLock)
|
||||
expect(release).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it("should release lock in case of failure", async () => {
|
||||
@@ -118,5 +156,48 @@ moduleIntegrationTestRunner<ILockingModule>({
|
||||
expect(fn_1).toBeCalledTimes(1)
|
||||
expect(fn_2).toBeCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should release lock in case of timeout failure", async () => {
|
||||
const fn_1 = jest.fn(async () => {
|
||||
await setTimeout(1010)
|
||||
return "fn_1"
|
||||
})
|
||||
|
||||
const fn_2 = jest.fn(async () => {
|
||||
return "fn_2"
|
||||
})
|
||||
|
||||
const fn_3 = jest.fn(async () => {
|
||||
return "fn_3"
|
||||
})
|
||||
|
||||
const ops = [
|
||||
service
|
||||
.execute("lock_key", fn_1, {
|
||||
timeout: 1,
|
||||
})
|
||||
.catch((e) => e),
|
||||
|
||||
service
|
||||
.execute("lock_key", fn_2, {
|
||||
timeout: 1,
|
||||
})
|
||||
.catch((e) => e),
|
||||
|
||||
service
|
||||
.execute("lock_key", fn_3, {
|
||||
timeout: 2,
|
||||
})
|
||||
.catch((e) => e),
|
||||
]
|
||||
|
||||
const res = await promiseAll(ops)
|
||||
|
||||
expect(res).toEqual(["fn_1", Error("Timed-out acquiring lock."), "fn_3"])
|
||||
|
||||
expect(fn_1).toHaveBeenCalledTimes(1)
|
||||
expect(fn_2).toHaveBeenCalledTimes(0)
|
||||
expect(fn_3).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/locking"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/__tests__",
|
||||
"!dist/**/__mocks__",
|
||||
"!dist/**/__fixtures__"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Module, Modules } from "@medusajs/framework/utils"
|
||||
import { LockingModuleService } from "@services"
|
||||
import loadProviders from "./loaders/providers"
|
||||
import { default as loadProviders } from "./loaders/providers"
|
||||
import LockingModuleService from "./services/locking-module"
|
||||
|
||||
export default Module(Modules.LOCKING, {
|
||||
service: LockingModuleService,
|
||||
|
||||
@@ -11,19 +11,13 @@ import {
|
||||
LockingIdentifiersRegistrationName,
|
||||
LockingProviderRegistrationPrefix,
|
||||
} from "@types"
|
||||
import { Lifetime, asFunction, asValue } from "awilix"
|
||||
import { Lifetime, aliasTo, asFunction, asValue } from "awilix"
|
||||
import { InMemoryLockingProvider } from "../providers/in-memory"
|
||||
|
||||
const registrationFn = async (klass, container, pluginOptions) => {
|
||||
const registrationFn = async (klass, container) => {
|
||||
const key = LockingProviderService.getRegistrationIdentifier(klass)
|
||||
|
||||
container.register({
|
||||
[LockingProviderRegistrationPrefix + key]: asFunction(
|
||||
(cradle) => new klass(cradle, pluginOptions.options),
|
||||
{
|
||||
lifetime: klass.LIFE_TIME || Lifetime.SINGLETON,
|
||||
}
|
||||
),
|
||||
[LockingProviderRegistrationPrefix + key]: aliasTo("__providers__" + key),
|
||||
})
|
||||
|
||||
container.registerAdd(LockingIdentifiersRegistrationName, asValue(key))
|
||||
|
||||
@@ -38,24 +38,27 @@ export class InMemoryLockingProvider implements ILockingProvider {
|
||||
timeout?: number
|
||||
}
|
||||
): Promise<T> {
|
||||
keys = Array.isArray(keys) ? keys : [keys]
|
||||
|
||||
const timeoutSeconds = args?.timeout ?? 5
|
||||
const timeout = Math.max(args?.timeout ?? 5, 1)
|
||||
const timeoutSeconds = Number.isNaN(timeout) ? 1 : timeout
|
||||
|
||||
const cancellationToken = { cancelled: false }
|
||||
const promises: Promise<any>[] = []
|
||||
if (timeoutSeconds > 0) {
|
||||
promises.push(this.getTimeout(timeoutSeconds))
|
||||
promises.push(this.getTimeout(timeoutSeconds, cancellationToken))
|
||||
}
|
||||
|
||||
promises.push(
|
||||
this.acquire(keys, {
|
||||
awaitQueue: true,
|
||||
})
|
||||
this.acquire_(
|
||||
keys,
|
||||
{
|
||||
expire: timeoutSeconds,
|
||||
awaitQueue: true,
|
||||
},
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
|
||||
await Promise.race(promises).catch(async (err) => {
|
||||
await this.release(keys)
|
||||
})
|
||||
await Promise.race(promises)
|
||||
|
||||
try {
|
||||
return await job()
|
||||
@@ -71,6 +74,18 @@ export class InMemoryLockingProvider implements ILockingProvider {
|
||||
expire?: number
|
||||
awaitQueue?: boolean
|
||||
}
|
||||
): Promise<void> {
|
||||
return this.acquire_(keys, args)
|
||||
}
|
||||
|
||||
async acquire_(
|
||||
keys: string | string[],
|
||||
args?: {
|
||||
ownerId?: string | null
|
||||
expire?: number
|
||||
awaitQueue?: boolean
|
||||
},
|
||||
cancellationToken?: { cancelled: boolean }
|
||||
): Promise<void> {
|
||||
keys = Array.isArray(keys) ? keys : [keys]
|
||||
const { ownerId, expire } = args ?? {}
|
||||
@@ -100,7 +115,7 @@ export class InMemoryLockingProvider implements ILockingProvider {
|
||||
continue
|
||||
}
|
||||
|
||||
if (lock.ownerId === ownerId) {
|
||||
if (lock.ownerId !== null && lock.ownerId === ownerId) {
|
||||
if (expire) {
|
||||
lock.expiration = now + expire * 1000
|
||||
this.locks.set(key, lock)
|
||||
@@ -111,10 +126,14 @@ export class InMemoryLockingProvider implements ILockingProvider {
|
||||
|
||||
if (lock.currentPromise && args?.awaitQueue) {
|
||||
await lock.currentPromise.promise
|
||||
if (cancellationToken?.cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.acquire(keys, args)
|
||||
}
|
||||
|
||||
throw new Error(`"${key}" is already locked.`)
|
||||
throw new Error(`Failed to acquire lock for key "${key}"`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,9 +185,13 @@ export class InMemoryLockingProvider implements ILockingProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private async getTimeout(seconds: number): Promise<void> {
|
||||
private async getTimeout(
|
||||
seconds: number,
|
||||
cancellationToken: { cancelled: boolean }
|
||||
): Promise<void> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
cancellationToken.cancelled = true
|
||||
reject(new Error("Timed-out acquiring lock."))
|
||||
}, seconds * 1000)
|
||||
})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
||||
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||
import { EmailPassAuthService } from "./services/emailpass"
|
||||
|
||||
const services = [EmailPassAuthService]
|
||||
|
||||
const providerExport: ModuleProviderExports = {
|
||||
export default ModuleProvider(Modules.AUTH, {
|
||||
services,
|
||||
}
|
||||
|
||||
export default providerExport
|
||||
})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
||||
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||
import { GithubAuthService } from "./services/github"
|
||||
|
||||
const services = [GithubAuthService]
|
||||
|
||||
const providerExport: ModuleProviderExports = {
|
||||
export default ModuleProvider(Modules.AUTH, {
|
||||
services,
|
||||
}
|
||||
|
||||
export default providerExport
|
||||
})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
||||
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||
import { GoogleAuthService } from "./services/google"
|
||||
|
||||
const services = [GoogleAuthService]
|
||||
|
||||
const providerExport: ModuleProviderExports = {
|
||||
export default ModuleProvider(Modules.AUTH, {
|
||||
services,
|
||||
}
|
||||
|
||||
export default providerExport
|
||||
})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
||||
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||
import { LocalFileService } from "./services/local-file"
|
||||
|
||||
const services = [LocalFileService]
|
||||
|
||||
const providerExport: ModuleProviderExports = {
|
||||
export default ModuleProvider(Modules.FILE, {
|
||||
services,
|
||||
}
|
||||
|
||||
export default providerExport
|
||||
})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
||||
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||
import { S3FileService } from "./services/s3-file"
|
||||
|
||||
const services = [S3FileService]
|
||||
|
||||
const providerExport: ModuleProviderExports = {
|
||||
export default ModuleProvider(Modules.FILE, {
|
||||
services,
|
||||
}
|
||||
|
||||
export default providerExport
|
||||
})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
||||
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||
import { ManualFulfillmentService } from "./services/manual-fulfillment"
|
||||
|
||||
const services = [ManualFulfillmentService]
|
||||
|
||||
const providerExport: ModuleProviderExports = {
|
||||
export default ModuleProvider(Modules.FULFILLMENT, {
|
||||
services,
|
||||
}
|
||||
|
||||
export default providerExport
|
||||
})
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
node_modules
|
||||
.DS_store
|
||||
yarn.lock
|
||||
@@ -0,0 +1,220 @@
|
||||
import { ILockingModule } from "@medusajs/framework/types"
|
||||
import { Modules, promiseAll } from "@medusajs/framework/utils"
|
||||
import { moduleIntegrationTestRunner } from "medusa-test-utils"
|
||||
import { setTimeout } from "node:timers/promises"
|
||||
|
||||
jest.setTimeout(5000)
|
||||
|
||||
const providerId = "locking-redis"
|
||||
moduleIntegrationTestRunner<ILockingModule>({
|
||||
moduleName: Modules.LOCKING,
|
||||
moduleOptions: {
|
||||
providers: [
|
||||
{
|
||||
id: providerId,
|
||||
resolve: require.resolve("../../src"),
|
||||
is_default: true,
|
||||
options: {
|
||||
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
testSuite: ({ service }) => {
|
||||
describe("Locking Module Service", () => {
|
||||
let stock = 5
|
||||
function replenishStock() {
|
||||
stock = 5
|
||||
}
|
||||
function hasStock() {
|
||||
return stock > 0
|
||||
}
|
||||
async function reduceStock() {
|
||||
await setTimeout(10)
|
||||
stock--
|
||||
}
|
||||
async function buy() {
|
||||
if (hasStock()) {
|
||||
await reduceStock()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await service.releaseAll()
|
||||
})
|
||||
|
||||
it("should execute functions respecting the key locked", async () => {
|
||||
// 10 parallel calls to buy should oversell the stock
|
||||
const prom: any[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
prom.push(buy())
|
||||
}
|
||||
await Promise.all(prom)
|
||||
expect(stock).toBe(-5)
|
||||
|
||||
replenishStock()
|
||||
|
||||
// 10 parallel calls to buy with lock should not oversell the stock
|
||||
const promWLock: any[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promWLock.push(service.execute("item_1", buy))
|
||||
}
|
||||
await Promise.all(promWLock)
|
||||
|
||||
expect(stock).toBe(0)
|
||||
})
|
||||
|
||||
it("should acquire lock and release it", async () => {
|
||||
await service.acquire("key_name", {
|
||||
ownerId: "user_id_123",
|
||||
})
|
||||
|
||||
const userReleased = await service.release("key_name", {
|
||||
ownerId: "user_id_456",
|
||||
})
|
||||
const anotherUserLock = service.acquire("key_name", {
|
||||
ownerId: "user_id_456",
|
||||
})
|
||||
|
||||
expect(userReleased).toBe(false)
|
||||
await expect(anotherUserLock).rejects.toThrow(
|
||||
`Failed to acquire lock for key "key_name"`
|
||||
)
|
||||
|
||||
const releasing = await service.release("key_name", {
|
||||
ownerId: "user_id_123",
|
||||
})
|
||||
|
||||
expect(releasing).toBe(true)
|
||||
})
|
||||
|
||||
it("should acquire lock and release it during parallel calls", async () => {
|
||||
const keyToLock = "mySpecialKey"
|
||||
const user_1 = {
|
||||
ownerId: "user_id_456",
|
||||
}
|
||||
const user_2 = {
|
||||
ownerId: "user_id_000",
|
||||
}
|
||||
|
||||
await expect(
|
||||
service.acquire(keyToLock, user_1)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
await expect(
|
||||
service.acquire(keyToLock, user_1)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
await expect(service.acquire(keyToLock, user_2)).rejects.toThrow(
|
||||
`Failed to acquire lock for key "${keyToLock}"`
|
||||
)
|
||||
|
||||
await expect(service.acquire(keyToLock, user_2)).rejects.toThrow(
|
||||
`Failed to acquire lock for key "${keyToLock}"`
|
||||
)
|
||||
|
||||
await service.acquire(keyToLock, user_1)
|
||||
|
||||
const releaseNotLocked = await service.release(keyToLock, {
|
||||
ownerId: "user_id_000",
|
||||
})
|
||||
expect(releaseNotLocked).toBe(false)
|
||||
|
||||
const release = await service.release(keyToLock, user_1)
|
||||
expect(release).toBe(true)
|
||||
})
|
||||
|
||||
it("should fail to acquire the same key when no owner is provided", async () => {
|
||||
const keyToLock = "mySpecialKey"
|
||||
|
||||
const user_2 = {
|
||||
ownerId: "user_id_000",
|
||||
}
|
||||
|
||||
await expect(service.acquire(keyToLock)).resolves.toBeUndefined()
|
||||
|
||||
await expect(service.acquire(keyToLock)).rejects.toThrow(
|
||||
`Failed to acquire lock for key "${keyToLock}"`
|
||||
)
|
||||
|
||||
await expect(service.acquire(keyToLock)).rejects.toThrow(
|
||||
`Failed to acquire lock for key "${keyToLock}"`
|
||||
)
|
||||
|
||||
await expect(service.acquire(keyToLock, user_2)).rejects.toThrow(
|
||||
`Failed to acquire lock for key "${keyToLock}"`
|
||||
)
|
||||
|
||||
await expect(service.acquire(keyToLock, user_2)).rejects.toThrow(
|
||||
`Failed to acquire lock for key "${keyToLock}"`
|
||||
)
|
||||
|
||||
const releaseNotLocked = await service.release(keyToLock, {
|
||||
ownerId: "user_id_000",
|
||||
})
|
||||
expect(releaseNotLocked).toBe(false)
|
||||
|
||||
const release = await service.release(keyToLock)
|
||||
expect(release).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it("should release lock in case of failure", async () => {
|
||||
const fn_1 = jest.fn(async () => {
|
||||
throw new Error("Error")
|
||||
})
|
||||
const fn_2 = jest.fn(async () => {})
|
||||
|
||||
await service.execute("lock_key", fn_1).catch(() => {})
|
||||
await service.execute("lock_key", fn_2).catch(() => {})
|
||||
|
||||
expect(fn_1).toHaveBeenCalledTimes(1)
|
||||
expect(fn_2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should release lock in case of timeout failure", async () => {
|
||||
const fn_1 = jest.fn(async () => {
|
||||
await setTimeout(1010)
|
||||
return "fn_1"
|
||||
})
|
||||
|
||||
const fn_2 = jest.fn(async () => {
|
||||
return "fn_2"
|
||||
})
|
||||
|
||||
const fn_3 = jest.fn(async () => {
|
||||
return "fn_3"
|
||||
})
|
||||
|
||||
const ops = [
|
||||
service
|
||||
.execute("lock_key", fn_1, {
|
||||
timeout: 1,
|
||||
})
|
||||
.catch((e) => e),
|
||||
|
||||
service
|
||||
.execute("lock_key", fn_2, {
|
||||
timeout: 1,
|
||||
})
|
||||
.catch((e) => e),
|
||||
|
||||
service
|
||||
.execute("lock_key", fn_3, {
|
||||
timeout: 5,
|
||||
})
|
||||
.catch((e) => e),
|
||||
]
|
||||
|
||||
const res = await promiseAll(ops)
|
||||
|
||||
expect(res).toEqual(["fn_1", Error("Timed-out acquiring lock."), "fn_3"])
|
||||
|
||||
expect(fn_1).toHaveBeenCalledTimes(1)
|
||||
expect(fn_2).toHaveBeenCalledTimes(0)
|
||||
expect(fn_3).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
const defineJestConfig = require("../../../../define_jest_config")
|
||||
module.exports = defineJestConfig({
|
||||
moduleNameMapper: {
|
||||
"^@models": "<rootDir>/src/models",
|
||||
"^@services": "<rootDir>/src/services",
|
||||
"^@repositories": "<rootDir>/src/repositories",
|
||||
"^@types": "<rootDir>/src/types",
|
||||
"^@utils": "<rootDir>/src/utils",
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@medusajs/locking-redis",
|
||||
"version": "0.0.1",
|
||||
"description": "Redis Lock for Medusa",
|
||||
"main": "dist/index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/locking-redis"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/__tests__",
|
||||
"!dist/**/__mocks__",
|
||||
"!dist/**/__fixtures__"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"author": "Medusa",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@medusajs/framework": "^0.0.1",
|
||||
"@swc/core": "^1.7.28",
|
||||
"@swc/jest": "^0.2.36",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@medusajs/framework": "^0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ioredis": "^5.4.1"
|
||||
},
|
||||
"scripts": {
|
||||
"watch": "tsc --build --watch",
|
||||
"watch:test": "tsc --build tsconfig.spec.json --watch",
|
||||
"resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json",
|
||||
"build": "rimraf dist && tsc --build && npm run resolve:aliases",
|
||||
"test": "jest --passWithNoTests src",
|
||||
"test:integration": "jest --runInBand --forceExit -- integration-tests/**/__tests__/**/*.spec.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"medusa-providers",
|
||||
"medusa-providers-locking"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||
import Loader from "./loaders"
|
||||
import { RedisLockingProvider } from "./services/redis-lock"
|
||||
|
||||
const services = [RedisLockingProvider]
|
||||
const loaders = [Loader]
|
||||
|
||||
export default ModuleProvider(Modules.LOCKING, {
|
||||
services,
|
||||
loaders,
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Modules } from "@medusajs/framework/utils"
|
||||
import { ProviderLoaderOptions } from "@medusajs/types"
|
||||
import { RedisCacheModuleOptions } from "@types"
|
||||
import { asValue } from "awilix"
|
||||
import Redis from "ioredis"
|
||||
|
||||
export default async ({
|
||||
container,
|
||||
logger,
|
||||
options,
|
||||
moduleOptions,
|
||||
}: ProviderLoaderOptions): Promise<void> => {
|
||||
const { redisUrl, redisOptions, namespace } =
|
||||
options as RedisCacheModuleOptions
|
||||
|
||||
if (!redisUrl) {
|
||||
throw Error(
|
||||
`No "redisUrl" provided in "${Modules.LOCKING}" module, "locking-redis" provider options. It is required for the "locking-redis" Module provider.`
|
||||
)
|
||||
}
|
||||
|
||||
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 "locking-redis" provider established`)
|
||||
} catch (err) {
|
||||
logger?.error(
|
||||
`An error occurred while connecting to Redis in provider "locking-redis": ${err}`
|
||||
)
|
||||
}
|
||||
|
||||
container.register({
|
||||
redisClient: asValue(connection),
|
||||
prefix: asValue(namespace ?? "medusa_lock:"),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { promiseAll } from "@medusajs/framework/utils"
|
||||
import { ILockingProvider } from "@medusajs/types"
|
||||
import { RedisCacheModuleOptions } from "@types"
|
||||
import { Redis } from "ioredis"
|
||||
import { setTimeout } from "node:timers/promises"
|
||||
|
||||
export class RedisLockingProvider implements ILockingProvider {
|
||||
static identifier = "locking-redis"
|
||||
|
||||
protected redisClient: Redis & {
|
||||
acquireLock: (
|
||||
key: string,
|
||||
ownerId: string,
|
||||
ttl: number,
|
||||
awaitQueue?: boolean
|
||||
) => Promise<number>
|
||||
releaseLock: (key: string, ownerId: string) => Promise<number>
|
||||
}
|
||||
protected keyNamePrefix: string
|
||||
protected waitLockingTimeout: number = 5
|
||||
protected defaultRetryInterval: number = 5
|
||||
protected maximumRetryInterval: number = 200
|
||||
|
||||
constructor({ redisClient, prefix }, options: RedisCacheModuleOptions) {
|
||||
this.redisClient = redisClient
|
||||
this.keyNamePrefix = prefix ?? "medusa_lock:"
|
||||
|
||||
if (!isNaN(+options?.waitLockingTimeout!)) {
|
||||
this.waitLockingTimeout = +options.waitLockingTimeout!
|
||||
}
|
||||
|
||||
if (!isNaN(+options?.defaultRetryInterval!)) {
|
||||
this.defaultRetryInterval = +options.defaultRetryInterval!
|
||||
}
|
||||
|
||||
if (!isNaN(+options?.maximumRetryInterval!)) {
|
||||
this.maximumRetryInterval = +options.maximumRetryInterval!
|
||||
}
|
||||
|
||||
// Define the custom command for acquiring locks
|
||||
this.redisClient.defineCommand("acquireLock", {
|
||||
numberOfKeys: 1,
|
||||
lua: `
|
||||
local key = KEYS[1]
|
||||
local ownerId = ARGV[1]
|
||||
local ttl = tonumber(ARGV[2])
|
||||
local awaitQueue = ARGV[3] == 'true'
|
||||
|
||||
local setArgs = {key, ownerId, 'NX'}
|
||||
if ttl > 0 then
|
||||
table.insert(setArgs, 'EX')
|
||||
table.insert(setArgs, ttl)
|
||||
end
|
||||
|
||||
local setResult = redis.call('SET', unpack(setArgs))
|
||||
|
||||
if setResult then
|
||||
return 1
|
||||
elseif not awaitQueue then
|
||||
-- Key already exists; retrieve the current ownerId
|
||||
local currentOwnerId = redis.call('GET', key)
|
||||
if currentOwnerId == '*' then
|
||||
return 0
|
||||
elseif currentOwnerId == ownerId then
|
||||
setArgs = {key, ownerId, 'XX'}
|
||||
if ttl > 0 then
|
||||
table.insert(setArgs, 'EX')
|
||||
table.insert(setArgs, ttl)
|
||||
end
|
||||
redis.call('SET', unpack(setArgs))
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
end
|
||||
else
|
||||
return 0
|
||||
end
|
||||
|
||||
`,
|
||||
})
|
||||
|
||||
// Define the custom command for releasing locks
|
||||
this.redisClient.defineCommand("releaseLock", {
|
||||
numberOfKeys: 1,
|
||||
lua: `
|
||||
local key = KEYS[1]
|
||||
local ownerId = ARGV[1]
|
||||
|
||||
if redis.call('GET', key) == ownerId then
|
||||
return redis.call('DEL', key)
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
private getKeyName(key: string): string {
|
||||
return `${this.keyNamePrefix}${key}`
|
||||
}
|
||||
|
||||
async execute<T>(
|
||||
keys: string | string[],
|
||||
job: () => Promise<T>,
|
||||
args?: {
|
||||
timeout?: number
|
||||
}
|
||||
): Promise<T> {
|
||||
const timeout = Math.max(args?.timeout ?? this.waitLockingTimeout, 1)
|
||||
const timeoutSeconds = Number.isNaN(timeout) ? 1 : timeout
|
||||
|
||||
const cancellationToken = { cancelled: false }
|
||||
const promises: Promise<any>[] = []
|
||||
if (timeoutSeconds > 0) {
|
||||
promises.push(this.getTimeout(timeoutSeconds, cancellationToken))
|
||||
}
|
||||
|
||||
promises.push(
|
||||
this.acquire_(
|
||||
keys,
|
||||
{
|
||||
awaitQueue: true,
|
||||
expire: args?.timeout ? timeoutSeconds : 0,
|
||||
},
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
|
||||
await Promise.race(promises)
|
||||
|
||||
try {
|
||||
return await job()
|
||||
} finally {
|
||||
await this.release(keys)
|
||||
}
|
||||
}
|
||||
|
||||
async acquire(
|
||||
keys: string | string[],
|
||||
args?: {
|
||||
ownerId?: string
|
||||
expire?: number
|
||||
awaitQueue?: boolean
|
||||
}
|
||||
): Promise<void> {
|
||||
return this.acquire_(keys, args)
|
||||
}
|
||||
|
||||
async acquire_(
|
||||
keys: string | string[],
|
||||
args?: {
|
||||
ownerId?: string
|
||||
expire?: number
|
||||
awaitQueue?: boolean
|
||||
},
|
||||
cancellationToken?: { cancelled: boolean }
|
||||
): Promise<void> {
|
||||
keys = Array.isArray(keys) ? keys : [keys]
|
||||
|
||||
const timeout = Math.max(args?.expire ?? this.waitLockingTimeout, 1)
|
||||
const timeoutSeconds = Number.isNaN(timeout) ? 1 : timeout
|
||||
let retryTimes = 0
|
||||
|
||||
const ownerId = args?.ownerId ?? "*"
|
||||
const awaitQueue = args?.awaitQueue ?? false
|
||||
|
||||
const acquirePromises = keys.map(async (key) => {
|
||||
const errMessage = `Failed to acquire lock for key "${key}"`
|
||||
const keyName = this.getKeyName(key)
|
||||
|
||||
const acquireLock = async () => {
|
||||
while (true) {
|
||||
if (cancellationToken?.cancelled) {
|
||||
throw new Error(errMessage)
|
||||
}
|
||||
|
||||
const result = await this.redisClient.acquireLock(
|
||||
keyName,
|
||||
ownerId,
|
||||
args?.expire ? timeoutSeconds : 0,
|
||||
awaitQueue
|
||||
)
|
||||
|
||||
if (result === 1) {
|
||||
break
|
||||
} else {
|
||||
if (awaitQueue) {
|
||||
// Wait for a short period before retrying
|
||||
await setTimeout(
|
||||
Math.min(
|
||||
this.defaultRetryInterval +
|
||||
(retryTimes / 10) * this.defaultRetryInterval,
|
||||
this.maximumRetryInterval
|
||||
)
|
||||
)
|
||||
retryTimes++
|
||||
} else {
|
||||
throw new Error(errMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await acquireLock()
|
||||
})
|
||||
|
||||
await promiseAll(acquirePromises)
|
||||
}
|
||||
|
||||
async release(
|
||||
keys: string | string[],
|
||||
args?: {
|
||||
ownerId?: string | null
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const ownerId = args?.ownerId ?? "*"
|
||||
keys = Array.isArray(keys) ? keys : [keys]
|
||||
|
||||
const releasePromises = keys.map(async (key) => {
|
||||
const keyName = this.getKeyName(key)
|
||||
const result = await this.redisClient.releaseLock(keyName, ownerId)
|
||||
return result === 1
|
||||
})
|
||||
|
||||
const results = await promiseAll(releasePromises)
|
||||
|
||||
return results.every((released) => released)
|
||||
}
|
||||
|
||||
async releaseAll(args?: { ownerId?: string | null }): Promise<void> {
|
||||
const ownerId = args?.ownerId ?? "*"
|
||||
|
||||
const pattern = `${this.keyNamePrefix}*`
|
||||
let cursor = "0"
|
||||
|
||||
do {
|
||||
const result = await this.redisClient.scan(
|
||||
cursor,
|
||||
"MATCH",
|
||||
pattern,
|
||||
"COUNT",
|
||||
100
|
||||
)
|
||||
cursor = result[0]
|
||||
const keys = result[1]
|
||||
|
||||
if (keys.length > 0) {
|
||||
const pipeline = this.redisClient.pipeline()
|
||||
|
||||
keys.forEach((key) => {
|
||||
pipeline.get(key)
|
||||
})
|
||||
|
||||
const currentOwners = await pipeline.exec()
|
||||
|
||||
const deletePipeline = this.redisClient.pipeline()
|
||||
keys.forEach((key, idx) => {
|
||||
const currentOwner = currentOwners?.[idx]?.[1]
|
||||
|
||||
if (currentOwner === ownerId) {
|
||||
deletePipeline.del(key)
|
||||
}
|
||||
})
|
||||
|
||||
await deletePipeline.exec()
|
||||
}
|
||||
} while (cursor !== "0")
|
||||
}
|
||||
|
||||
private async getTimeout(
|
||||
seconds: number,
|
||||
cancellationToken: { cancelled: boolean }
|
||||
): Promise<void> {
|
||||
return new Promise(async (_, reject) => {
|
||||
await setTimeout(seconds * 1000)
|
||||
cancellationToken.cancelled = true
|
||||
reject(new Error("Timed-out acquiring lock."))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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_lock:`
|
||||
*/
|
||||
namespace?: string
|
||||
|
||||
/**
|
||||
* Time to wait for lock (in seconds)
|
||||
* @default 5
|
||||
*/
|
||||
waitLockingTimeout?: number
|
||||
|
||||
/**
|
||||
* Default retry interval (in milliseconds)
|
||||
* @default 5
|
||||
*/
|
||||
defaultRetryInterval?: number
|
||||
|
||||
/**
|
||||
* Maximum retry interval (in milliseconds)
|
||||
* @default 200
|
||||
*/
|
||||
maximumRetryInterval?: number
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../../../_tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@models": ["./src/models"],
|
||||
"@services": ["./src/services"],
|
||||
"@repositories": ["./src/repositories"],
|
||||
"@types": ["./src/types"],
|
||||
"@utils": ["./src/utils"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
||||
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||
import { LocalNotificationService } from "./services/local"
|
||||
|
||||
const services = [LocalNotificationService]
|
||||
|
||||
const providerExport: ModuleProviderExports = {
|
||||
export default ModuleProvider(Modules.NOTIFICATION, {
|
||||
services,
|
||||
}
|
||||
|
||||
export default providerExport
|
||||
})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
||||
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||
import { SendgridNotificationService } from "./services/sendgrid"
|
||||
|
||||
const services = [SendgridNotificationService]
|
||||
|
||||
const providerExport: ModuleProviderExports = {
|
||||
export default ModuleProvider(Modules.NOTIFICATION, {
|
||||
services,
|
||||
}
|
||||
|
||||
export default providerExport
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ModuleProviderExports } from "@medusajs/framework/types"
|
||||
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||
import {
|
||||
StripeBancontactService,
|
||||
StripeBlikService,
|
||||
@@ -17,8 +17,6 @@ const services = [
|
||||
StripePrzelewy24Service,
|
||||
]
|
||||
|
||||
const providerExport: ModuleProviderExports = {
|
||||
export default ModuleProvider(Modules.PAYMENT, {
|
||||
services,
|
||||
}
|
||||
|
||||
export default providerExport
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user