From c8b375ae2d483c4467fef5168b363e40d79a6b59 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Fri, 11 Oct 2024 13:30:06 -0300 Subject: [PATCH] feat(locking): Locking module (#9524) **What** - Locking Module to manage concurrency - Default `in-memory` provider --- .changeset/little-books-reply.md | 1 + .eslintignore | 1 + .eslintrc.js | 1 + .gitignore | 1 + .../core/framework/src/types/container.ts | 32 ++-- packages/core/modules-sdk/src/definitions.ts | 54 +++--- packages/core/modules-sdk/src/medusa-app.ts | 4 +- packages/core/types/src/index.ts | 1 + packages/core/types/src/locking/index.ts | 69 +++++++ .../types/src/modules-sdk/module-provider.ts | 1 + .../core/utils/src/modules-sdk/definition.ts | 2 + .../modules-sdk/medusa-internal-service.ts | 2 +- packages/medusa/package.json | 1 + packages/medusa/src/modules/locking.ts | 6 + packages/modules/locking/.gitignore | 6 + .../integration-tests/__tests__/index.spec.ts | 122 ++++++++++++ packages/modules/locking/jest.config.js | 10 + .../modules/locking/mikro-orm.config.dev.ts | 6 + packages/modules/locking/package.json | 50 +++++ packages/modules/locking/src/index.ts | 11 ++ .../modules/locking/src/loaders/providers.ts | 85 +++++++++ .../locking/src/providers/in-memory.ts | 176 ++++++++++++++++++ .../modules/locking/src/services/index.ts | 2 + .../locking/src/services/locking-module.ts | 90 +++++++++ .../locking/src/services/locking-provider.ts | 40 ++++ packages/modules/locking/src/types/index.ts | 33 ++++ packages/modules/locking/tsconfig.json | 12 ++ yarn.lock | 26 +++ 28 files changed, 806 insertions(+), 39 deletions(-) create mode 100644 packages/core/types/src/locking/index.ts create mode 100644 packages/medusa/src/modules/locking.ts create mode 100644 packages/modules/locking/.gitignore create mode 100644 packages/modules/locking/integration-tests/__tests__/index.spec.ts create mode 100644 packages/modules/locking/jest.config.js create mode 100644 packages/modules/locking/mikro-orm.config.dev.ts create mode 100644 packages/modules/locking/package.json create mode 100644 packages/modules/locking/src/index.ts create mode 100644 packages/modules/locking/src/loaders/providers.ts create mode 100644 packages/modules/locking/src/providers/in-memory.ts create mode 100644 packages/modules/locking/src/services/index.ts create mode 100644 packages/modules/locking/src/services/locking-module.ts create mode 100644 packages/modules/locking/src/services/locking-provider.ts create mode 100644 packages/modules/locking/src/types/index.ts create mode 100644 packages/modules/locking/tsconfig.json diff --git a/.changeset/little-books-reply.md b/.changeset/little-books-reply.md index bfdb20bd28..b860e171fb 100644 --- a/.changeset/little-books-reply.md +++ b/.changeset/little-books-reply.md @@ -60,6 +60,7 @@ "@medusajs/admin-vite-plugin": patch "@medusajs/framework": patch "@medusajs/index": patch +"@medusajs/locking": patch --- chore: Preview release changeset diff --git a/.eslintignore b/.eslintignore index d2be7782af..66fa041a02 100644 --- a/.eslintignore +++ b/.eslintignore @@ -23,6 +23,7 @@ packages/* !packages/cache-inmemory !packages/create-medusa-app !packages/product +!packages/locking !packages/orchestration !packages/workflows-sdk !packages/core-flows diff --git a/.eslintrc.js b/.eslintrc.js index e7c49b3435..f960018932 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -128,6 +128,7 @@ module.exports = { "./packages/modules/workflow-engine-redis/tsconfig.spec.json", "./packages/modules/link-modules/tsconfig.spec.json", "./packages/modules/user/tsconfig.spec.json", + "./packages/modules/locking/tsconfig.spec.json", "./packages/modules/providers/file-local/tsconfig.spec.json", "./packages/modules/providers/file-s3/tsconfig.spec.json", diff --git a/.gitignore b/.gitignore index 37390fd46d..b2b09657d1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ www/**/.yarn/* .idea .turbo build/** +dist/** **/dist **/stats .favorites.json diff --git a/packages/core/framework/src/types/container.ts b/packages/core/framework/src/types/container.ts index 5bc1540ebf..0d0d3244ec 100644 --- a/packages/core/framework/src/types/container.ts +++ b/packages/core/framework/src/types/container.ts @@ -1,36 +1,37 @@ -import { Knex } from "@mikro-orm/knex" import { RemoteLink } from "@medusajs/modules-sdk" -import { AwilixContainer, ResolveOptions } from "awilix" -import { Modules, ContainerRegistrationKeys } from "@medusajs/utils" import { - Logger, ConfigModule, - ModuleImplementations, - RemoteQueryFunction, + IApiKeyModuleService, IAuthModuleService, ICacheService, ICartModuleService, + ICurrencyModuleService, ICustomerModuleService, IEventBusModuleService, + IFileModuleService, + IFulfillmentModuleService, IInventoryService, + ILockingModule, + INotificationModuleService, + IOrderModuleService, IPaymentModuleService, IPricingModuleService, IProductModuleService, IPromotionModuleService, + IRegionModuleService, ISalesChannelModuleService, - ITaxModuleService, - IFulfillmentModuleService, IStockLocationService, + IStoreModuleService, + ITaxModuleService, IUserModuleService, IWorkflowEngineService, - IRegionModuleService, - IOrderModuleService, - IApiKeyModuleService, - IStoreModuleService, - ICurrencyModuleService, - IFileModuleService, - INotificationModuleService, + Logger, + ModuleImplementations, + RemoteQueryFunction, } from "@medusajs/types" +import { ContainerRegistrationKeys, Modules } from "@medusajs/utils" +import { Knex } from "@mikro-orm/knex" +import { AwilixContainer, ResolveOptions } from "awilix" declare module "@medusajs/types" { export interface ModuleImplementations { @@ -63,6 +64,7 @@ declare module "@medusajs/types" { [Modules.CURRENCY]: ICurrencyModuleService [Modules.FILE]: IFileModuleService [Modules.NOTIFICATION]: INotificationModuleService + [Modules.LOCKING]: ILockingModule } } diff --git a/packages/core/modules-sdk/src/definitions.ts b/packages/core/modules-sdk/src/definitions.ts index 8771c702a6..ccf7f9e65d 100644 --- a/packages/core/modules-sdk/src/definitions.ts +++ b/packages/core/modules-sdk/src/definitions.ts @@ -16,7 +16,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.EVENT_BUS), isRequired: true, isQueryable: false, - dependencies: ["logger"], + dependencies: [ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -63,7 +63,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.PRODUCT), isRequired: false, isQueryable: true, - dependencies: [Modules.EVENT_BUS, "logger"], + dependencies: [Modules.EVENT_BUS, ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -75,7 +75,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.PRICING), isRequired: false, isQueryable: true, - dependencies: [Modules.EVENT_BUS, "logger"], + dependencies: [Modules.EVENT_BUS, ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -87,7 +87,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.PROMOTION), isRequired: false, isQueryable: true, - dependencies: ["logger"], + dependencies: [ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -99,7 +99,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.AUTH), isRequired: false, isQueryable: true, - dependencies: ["logger"], + dependencies: [ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -111,7 +111,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.WORKFLOW_ENGINE), isRequired: false, isQueryable: true, - dependencies: ["logger"], + dependencies: [ContainerRegistrationKeys.LOGGER], __passSharedContainer: true, defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, @@ -124,7 +124,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.SALES_CHANNEL), isRequired: false, isQueryable: true, - dependencies: ["logger"], + dependencies: [ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -136,7 +136,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.FULFILLMENT), isRequired: false, isQueryable: true, - dependencies: ["logger", Modules.EVENT_BUS], + dependencies: [ContainerRegistrationKeys.LOGGER, Modules.EVENT_BUS], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -148,7 +148,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.CART), isRequired: false, isQueryable: true, - dependencies: ["logger"], + dependencies: [ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -160,7 +160,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.CUSTOMER), isRequired: false, isQueryable: true, - dependencies: ["logger"], + dependencies: [ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -172,7 +172,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.PAYMENT), isRequired: false, isQueryable: true, - dependencies: ["logger"], + dependencies: [ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -184,7 +184,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.USER), isRequired: false, isQueryable: true, - dependencies: [Modules.EVENT_BUS, "logger"], + dependencies: [Modules.EVENT_BUS, ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -196,7 +196,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.REGION), isRequired: false, isQueryable: true, - dependencies: ["logger"], + dependencies: [ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -208,7 +208,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.ORDER), isRequired: false, isQueryable: true, - dependencies: ["logger", Modules.EVENT_BUS], + dependencies: [ContainerRegistrationKeys.LOGGER, Modules.EVENT_BUS], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -220,7 +220,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.TAX), isRequired: false, isQueryable: true, - dependencies: ["logger", Modules.EVENT_BUS], + dependencies: [ContainerRegistrationKeys.LOGGER, Modules.EVENT_BUS], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -232,7 +232,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.API_KEY), isRequired: false, isQueryable: true, - dependencies: ["logger"], + dependencies: [ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -244,7 +244,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.STORE), isRequired: false, isQueryable: true, - dependencies: ["logger"], + dependencies: [ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -256,7 +256,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.CURRENCY), isRequired: false, isQueryable: true, - dependencies: ["logger"], + dependencies: [ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -268,7 +268,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.FILE), isRequired: false, isQueryable: true, - dependencies: ["logger"], + dependencies: [ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -280,7 +280,7 @@ export const ModulesDefinition: { label: upperCaseFirst(Modules.NOTIFICATION), isRequired: false, isQueryable: true, - dependencies: [Modules.EVENT_BUS, "logger"], + dependencies: [Modules.EVENT_BUS, ContainerRegistrationKeys.LOGGER], defaultModuleDeclaration: { scope: MODULE_SCOPE.INTERNAL, resources: MODULE_RESOURCE_TYPE.SHARED, @@ -294,7 +294,7 @@ export const ModulesDefinition: { isQueryable: false, dependencies: [ Modules.EVENT_BUS, - "logger", + ContainerRegistrationKeys.LOGGER, ContainerRegistrationKeys.REMOTE_QUERY, ContainerRegistrationKeys.QUERY, ], @@ -303,6 +303,18 @@ export const ModulesDefinition: { resources: MODULE_RESOURCE_TYPE.SHARED, }, }, + [Modules.LOCKING]: { + key: Modules.LOCKING, + defaultPackage: false, + label: upperCaseFirst(Modules.LOCKING), + isRequired: false, + isQueryable: false, + dependencies: [ContainerRegistrationKeys.LOGGER], + defaultModuleDeclaration: { + scope: MODULE_SCOPE.INTERNAL, + resources: MODULE_RESOURCE_TYPE.SHARED, + }, + }, } export const MODULE_DEFINITIONS: ModuleDefinition[] = diff --git a/packages/core/modules-sdk/src/medusa-app.ts b/packages/core/modules-sdk/src/medusa-app.ts index 2befa97488..0b7e4ad904 100644 --- a/packages/core/modules-sdk/src/medusa-app.ts +++ b/packages/core/modules-sdk/src/medusa-app.ts @@ -84,7 +84,7 @@ export async function loadModules(args: { sharedResourcesConfig, migrationOnly = false, loaderOnly = false, - workerMode = "server" as ModuleBootstrapOptions["workerMode"], + workerMode = "shared" as ModuleBootstrapOptions["workerMode"], } = args const allModules = {} as any @@ -307,7 +307,7 @@ async function MedusaApp_({ injectedDependencies = {}, migrationOnly = false, loaderOnly = false, - workerMode = "server", + workerMode = "shared", }: MedusaAppOptions & { migrationOnly?: boolean } = {}): Promise { diff --git a/packages/core/types/src/index.ts b/packages/core/types/src/index.ts index e4a137401d..ee79df88a5 100644 --- a/packages/core/types/src/index.ts +++ b/packages/core/types/src/index.ts @@ -20,6 +20,7 @@ export * from "./index-data" export * from "./inventory" export * from "./joiner" export * from "./link-modules" +export * from "./locking" export * from "./logger" export * from "./modules-sdk" export * from "./notification" diff --git a/packages/core/types/src/locking/index.ts b/packages/core/types/src/locking/index.ts new file mode 100644 index 0000000000..6dbc4bc5be --- /dev/null +++ b/packages/core/types/src/locking/index.ts @@ -0,0 +1,69 @@ +import { Context } from "../shared-context" + +export interface ILockingProvider { + execute( + keys: string | string[], + job: () => Promise, + args?: { + timeout?: number + }, + sharedContext?: Context + ): Promise + acquire( + keys: string | string[], + args?: { + ownerId?: string | null + expire?: number + }, + sharedContext?: Context + ): Promise + release( + keys: string | string[], + args?: { + ownerId?: string | null + }, + sharedContext?: Context + ): Promise + releaseAll( + args?: { + ownerId?: string | null + }, + sharedContext?: Context + ): Promise +} + +export interface ILockingModule { + execute( + keys: string | string[], + job: () => Promise, + args?: { + timeout?: number + provider?: string + }, + sharedContext?: Context + ): Promise + acquire( + keys: string | string[], + args?: { + ownerId?: string | null + expire?: number + provider?: string + }, + sharedContext?: Context + ): Promise + release( + keys: string | string[], + args?: { + ownerId?: string | null + provider?: string + }, + sharedContext?: Context + ): Promise + releaseAll( + args?: { + ownerId?: string | null + provider?: string + }, + sharedContext?: Context + ): Promise +} diff --git a/packages/core/types/src/modules-sdk/module-provider.ts b/packages/core/types/src/modules-sdk/module-provider.ts index 1eff6ab132..33524247ab 100644 --- a/packages/core/types/src/modules-sdk/module-provider.ts +++ b/packages/core/types/src/modules-sdk/module-provider.ts @@ -8,4 +8,5 @@ export type ModuleProvider = { resolve: string | ModuleProviderExports id: string options?: Record + is_default?: boolean } diff --git a/packages/core/utils/src/modules-sdk/definition.ts b/packages/core/utils/src/modules-sdk/definition.ts index 4b9b76dc4c..25ff88a363 100644 --- a/packages/core/utils/src/modules-sdk/definition.ts +++ b/packages/core/utils/src/modules-sdk/definition.ts @@ -24,6 +24,7 @@ export const Modules = { FILE: "file", NOTIFICATION: "notification", INDEX: "index", + LOCKING: "locking", } as const export const MODULE_PACKAGE_NAMES = { @@ -52,6 +53,7 @@ export const MODULE_PACKAGE_NAMES = { [Modules.FILE]: "@medusajs/medusa/file", [Modules.NOTIFICATION]: "@medusajs/medusa/notification", [Modules.INDEX]: "@medusajs/medusa/index-module", + [Modules.LOCKING]: "@medusajs/medusa/locking", } export const REVERSED_MODULE_PACKAGE_NAMES = Object.entries( diff --git a/packages/core/utils/src/modules-sdk/medusa-internal-service.ts b/packages/core/utils/src/modules-sdk/medusa-internal-service.ts index 506ab979de..a7229c0d31 100644 --- a/packages/core/utils/src/modules-sdk/medusa-internal-service.ts +++ b/packages/core/utils/src/modules-sdk/medusa-internal-service.ts @@ -9,7 +9,7 @@ import { PerformedActions, UpsertWithReplaceConfig, } from "@medusajs/types" -import type { EntitySchema, EntityClass } from "@mikro-orm/core" +import type { EntityClass, EntitySchema } from "@mikro-orm/core" import { doNotForceTransaction, isDefined, diff --git a/packages/medusa/package.json b/packages/medusa/package.json index 63195b7482..5c510c282a 100644 --- a/packages/medusa/package.json +++ b/packages/medusa/package.json @@ -83,6 +83,7 @@ "@medusajs/index": "^0.0.1", "@medusajs/inventory-next": "^0.0.3", "@medusajs/link-modules": "^0.2.11", + "@medusajs/locking": "^0.0.1", "@medusajs/notification": "^0.1.2", "@medusajs/notification-local": "^0.0.1", "@medusajs/notification-sendgrid": "^0.0.1", diff --git a/packages/medusa/src/modules/locking.ts b/packages/medusa/src/modules/locking.ts new file mode 100644 index 0000000000..f7ae14bdc7 --- /dev/null +++ b/packages/medusa/src/modules/locking.ts @@ -0,0 +1,6 @@ +import LockingModule from "@medusajs/locking" + +export * from "@medusajs/locking" + +export default LockingModule +export const discoveryPath = require.resolve("@medusajs/locking") diff --git a/packages/modules/locking/.gitignore b/packages/modules/locking/.gitignore new file mode 100644 index 0000000000..874c6c69d3 --- /dev/null +++ b/packages/modules/locking/.gitignore @@ -0,0 +1,6 @@ +/dist +node_modules +.DS_store +.env* +.env +*.sql diff --git a/packages/modules/locking/integration-tests/__tests__/index.spec.ts b/packages/modules/locking/integration-tests/__tests__/index.spec.ts new file mode 100644 index 0000000000..d6c44467e7 --- /dev/null +++ b/packages/modules/locking/integration-tests/__tests__/index.spec.ts @@ -0,0 +1,122 @@ +import { ILockingModule } from "@medusajs/framework/types" +import { Modules } from "@medusajs/framework/utils" +import { moduleIntegrationTestRunner } from "medusa-test-utils" +import { setTimeout } from "node:timers/promises" + +jest.setTimeout(10000) + +moduleIntegrationTestRunner({ + moduleName: Modules.LOCKING, + 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 + } + + 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.toThrowError( + `"key_name" is already locked.` + ) + + 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", + } + + expect(service.acquire(keyToLock, user_1)).resolves.toBeUndefined() + + expect(service.acquire(keyToLock, user_1)).resolves.toBeUndefined() + + expect(service.acquire(keyToLock, user_2)).rejects.toThrowError( + `"${keyToLock}" is already locked.` + ) + + expect(service.acquire(keyToLock, user_2)).rejects.toThrowError( + `"${keyToLock}" is already locked.` + ) + + 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 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).toBeCalledTimes(1) + expect(fn_2).toBeCalledTimes(1) + }) + }, +}) diff --git a/packages/modules/locking/jest.config.js b/packages/modules/locking/jest.config.js new file mode 100644 index 0000000000..3aab9b7072 --- /dev/null +++ b/packages/modules/locking/jest.config.js @@ -0,0 +1,10 @@ +const defineJestConfig = require("../../../define_jest_config") +module.exports = defineJestConfig({ + moduleNameMapper: { + "^@models": "/src/models", + "^@services": "/src/services", + "^@repositories": "/src/repositories", + "^@types": "/src/types", + "^@utils": "/src/utils", + }, +}) diff --git a/packages/modules/locking/mikro-orm.config.dev.ts b/packages/modules/locking/mikro-orm.config.dev.ts new file mode 100644 index 0000000000..e30d36a644 --- /dev/null +++ b/packages/modules/locking/mikro-orm.config.dev.ts @@ -0,0 +1,6 @@ +import { defineMikroOrmCliConfig, Modules } from "@medusajs/framework/utils" +import * as entities from "./src/models" + +export default defineMikroOrmCliConfig(Modules.LOCKING, { + entities: Object.values(entities), +}) diff --git a/packages/modules/locking/package.json b/packages/modules/locking/package.json new file mode 100644 index 0000000000..a78a0bdea0 --- /dev/null +++ b/packages/modules/locking/package.json @@ -0,0 +1,50 @@ +{ + "name": "@medusajs/locking", + "version": "0.0.1", + "description": "Locking Module for Medusa", + "main": "dist/index.js", + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/locking" + }, + "publishConfig": { + "access": "public" + }, + "author": "Medusa", + "license": "MIT", + "scripts": { + "watch": "tsc --build --watch", + "watch:test": "tsc --build tsconfig.spec.json --watch", + "resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json", + "build": "rimraf dist && tsc --build && npm run resolve:aliases", + "test": "jest --passWithNoTests --runInBand --bail --forceExit -- src/", + "test:integration": "jest --runInBand --forceExit -- integration-tests/**/__tests__/**/*.spec.ts", + "migration:generate": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts medusa-mikro-orm migration:generate", + "migration:initial": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create --initial -n InitialSetupMigration", + "migration:create": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create", + "migration:up": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts medusa-mikro-orm migration:up", + "orm:cache:clear": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts medusa-mikro-orm cache:clear" + }, + "devDependencies": { + "@medusajs/framework": "^0.0.1", + "@mikro-orm/cli": "5.9.7", + "@mikro-orm/core": "5.9.7", + "@mikro-orm/migrations": "5.9.7", + "@mikro-orm/postgresql": "5.9.7", + "@swc/core": "^1.7.28", + "@swc/jest": "^0.2.36", + "jest": "^29.7.0", + "medusa-test-utils": "^1.1.44", + "rimraf": "^3.0.2", + "tsc-alias": "^1.8.6", + "typescript": "^5.6.2" + }, + "peerDependencies": { + "@medusajs/framework": "^0.0.1", + "@mikro-orm/core": "5.9.7", + "@mikro-orm/migrations": "5.9.7", + "@mikro-orm/postgresql": "5.9.7", + "awilix": "^8.0.1" + } +} diff --git a/packages/modules/locking/src/index.ts b/packages/modules/locking/src/index.ts new file mode 100644 index 0000000000..d432f2fa58 --- /dev/null +++ b/packages/modules/locking/src/index.ts @@ -0,0 +1,11 @@ +import { Module, Modules } from "@medusajs/framework/utils" +import { LockingModuleService } from "@services" +import loadProviders from "./loaders/providers" + +export default Module(Modules.LOCKING, { + service: LockingModuleService, + loaders: [loadProviders], +}) + +// Module options types +export { LockingModuleOptions } from "./types" diff --git a/packages/modules/locking/src/loaders/providers.ts b/packages/modules/locking/src/loaders/providers.ts new file mode 100644 index 0000000000..7e59bb9c90 --- /dev/null +++ b/packages/modules/locking/src/loaders/providers.ts @@ -0,0 +1,85 @@ +import { moduleProviderLoader } from "@medusajs/framework/modules-sdk" +import { + LoaderOptions, + ModuleProvider, + ModulesSdkTypes, +} from "@medusajs/framework/types" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" +import { LockingProviderService } from "@services" +import { + LockingDefaultProvider, + LockingIdentifiersRegistrationName, + LockingProviderRegistrationPrefix, +} from "@types" +import { Lifetime, asFunction, asValue } from "awilix" +import { InMemoryLockingProvider } from "../providers/in-memory" + +const registrationFn = async (klass, container, pluginOptions) => { + const key = LockingProviderService.getRegistrationIdentifier(klass) + + container.register({ + [LockingProviderRegistrationPrefix + key]: asFunction( + (cradle) => new klass(cradle, pluginOptions.options), + { + lifetime: klass.LIFE_TIME || Lifetime.SINGLETON, + } + ), + }) + + container.registerAdd(LockingIdentifiersRegistrationName, asValue(key)) +} + +export default async ({ + container, + options, +}: LoaderOptions< + ( + | ModulesSdkTypes.ModuleServiceInitializeOptions + | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions + ) & { providers: ModuleProvider[] } +>): Promise => { + const logger = container.resolve(ContainerRegistrationKeys.LOGGER) + container.registerAdd(LockingIdentifiersRegistrationName, asValue(undefined)) + + // InMemoryLockingProvider - default provider + container.register({ + [LockingProviderRegistrationPrefix + InMemoryLockingProvider.identifier]: + asFunction((cradle) => new InMemoryLockingProvider(), { + lifetime: Lifetime.SINGLETON, + }), + }) + container.registerAdd( + LockingIdentifiersRegistrationName, + asValue(InMemoryLockingProvider.identifier) + ) + container.register( + LockingDefaultProvider, + asValue(InMemoryLockingProvider.identifier) + ) + + // Load other providers + await moduleProviderLoader({ + container, + providers: options?.providers || [], + registerServiceFn: registrationFn, + }) + + const isSingleProvider = options?.providers?.length === 1 + let hasDefaultProvider = false + for (const provider of options?.providers || []) { + if (provider.is_default || isSingleProvider) { + if (provider.is_default) { + hasDefaultProvider = true + } + container.register(LockingDefaultProvider, asValue(provider.id)) + } + } + + if (!hasDefaultProvider) { + logger.warn( + `No default locking provider explicit defined. Using "${container.resolve( + LockingDefaultProvider + )}" as default.` + ) + } +} diff --git a/packages/modules/locking/src/providers/in-memory.ts b/packages/modules/locking/src/providers/in-memory.ts new file mode 100644 index 0000000000..acb327bb84 --- /dev/null +++ b/packages/modules/locking/src/providers/in-memory.ts @@ -0,0 +1,176 @@ +import { ILockingProvider } from "@medusajs/framework/types" +import { isDefined } from "@medusajs/framework/utils" + +type LockInfo = { + ownerId: string | null + expiration: number | null + currentPromise?: ResolvablePromise +} + +type ResolvablePromise = { + promise: Promise + resolve: () => void +} + +export class InMemoryLockingProvider implements ILockingProvider { + static identifier = "in-memory" + + private locks: Map = new Map() + + constructor() {} + + private getPromise(): ResolvablePromise { + let resolve: any + const pro = new Promise((ok) => { + resolve = ok + }) + + return { + promise: pro, + resolve, + } + } + + async execute( + keys: string | string[], + job: () => Promise, + args?: { + timeout?: number + } + ): Promise { + keys = Array.isArray(keys) ? keys : [keys] + + const timeoutSeconds = args?.timeout ?? 5 + + const promises: Promise[] = [] + if (timeoutSeconds > 0) { + promises.push(this.getTimeout(timeoutSeconds)) + } + + promises.push( + this.acquire(keys, { + awaitQueue: true, + }) + ) + + await Promise.race(promises).catch(async (err) => { + await this.release(keys) + }) + + try { + return await job() + } finally { + await this.release(keys) + } + } + + async acquire( + keys: string | string[], + args?: { + ownerId?: string | null + expire?: number + awaitQueue?: boolean + } + ): Promise { + keys = Array.isArray(keys) ? keys : [keys] + const { ownerId, expire } = args ?? {} + + for (const key of keys) { + const lock = this.locks.get(key) + const now = Date.now() + + if (!lock) { + this.locks.set(key, { + ownerId: ownerId ?? null, + expiration: expire ? now + expire * 1000 : null, + currentPromise: this.getPromise(), + }) + + continue + } + + if (lock.expiration && lock.expiration <= now) { + lock.currentPromise?.resolve?.() + this.locks.set(key, { + ownerId: ownerId ?? null, + expiration: expire ? now + expire * 1000 : null, + currentPromise: this.getPromise(), + }) + + continue + } + + if (lock.ownerId === ownerId) { + if (expire) { + lock.expiration = now + expire * 1000 + this.locks.set(key, lock) + } + + continue + } + + if (lock.currentPromise && args?.awaitQueue) { + await lock.currentPromise.promise + return this.acquire(keys, args) + } + + throw new Error(`"${key}" is already locked.`) + } + } + + async release( + keys: string | string[], + args?: { + ownerId?: string | null + } + ): Promise { + const { ownerId } = args ?? {} + keys = Array.isArray(keys) ? keys : [keys] + + let success = true + + for (const key of keys) { + const lock = this.locks.get(key) + if (!lock) { + success = false + continue + } + + if (isDefined(ownerId) && lock.ownerId !== ownerId) { + success = false + continue + } + + lock.currentPromise?.resolve?.() + this.locks.delete(key) + } + + return success + } + + async releaseAll(args?: { ownerId?: string | null }): Promise { + const { ownerId } = args ?? {} + + if (!isDefined(ownerId)) { + for (const [key, lock] of this.locks.entries()) { + lock.currentPromise?.resolve?.() + this.locks.delete(key) + } + } else { + for (const [key, lock] of this.locks.entries()) { + if (lock.ownerId === ownerId) { + lock.currentPromise?.resolve?.() + this.locks.delete(key) + } + } + } + } + + private async getTimeout(seconds: number): Promise { + return new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Timed-out acquiring lock.")) + }, seconds * 1000) + }) + } +} diff --git a/packages/modules/locking/src/services/index.ts b/packages/modules/locking/src/services/index.ts new file mode 100644 index 0000000000..acaf4eca46 --- /dev/null +++ b/packages/modules/locking/src/services/index.ts @@ -0,0 +1,2 @@ +export { default as LockingModuleService } from "./locking-module" +export { default as LockingProviderService } from "./locking-provider" diff --git a/packages/modules/locking/src/services/locking-module.ts b/packages/modules/locking/src/services/locking-module.ts new file mode 100644 index 0000000000..6945da8549 --- /dev/null +++ b/packages/modules/locking/src/services/locking-module.ts @@ -0,0 +1,90 @@ +import { + Context, + ILockingModule, + InternalModuleDeclaration, +} from "@medusajs/types" +import { EntityManager } from "@mikro-orm/core" +import { LockingDefaultProvider } from "@types" +import LockingProviderService from "./locking-provider" + +type InjectedDependencies = { + manager: EntityManager + lockingProviderService: LockingProviderService + [LockingDefaultProvider]: string +} + +export default class LockingModuleService implements ILockingModule { + protected manager: EntityManager + protected providerService_: LockingProviderService + protected defaultProviderId: string + + constructor( + container: InjectedDependencies, + protected readonly moduleDeclaration: InternalModuleDeclaration + ) { + this.manager = container.manager + this.providerService_ = container.lockingProviderService + this.defaultProviderId = container[LockingDefaultProvider] + } + + async execute( + keys: string | string[], + job: () => Promise, + args?: { + timeout?: number + provider?: string + }, + sharedContext: Context = {} + ): Promise { + const providerId = args?.provider ?? this.defaultProviderId + const provider = + this.providerService_.retrieveProviderRegistration(providerId) + + return provider.execute(keys, job, args, sharedContext) + } + + async acquire( + keys: string | string[], + args?: { + ownerId?: string | null + expire?: number + provider?: string + }, + sharedContext: Context = {} + ): Promise { + const providerId = args?.provider ?? this.defaultProviderId + const provider = + this.providerService_.retrieveProviderRegistration(providerId) + + await provider.acquire(keys, args, sharedContext) + } + + async release( + keys: string | string[], + args?: { + ownerId?: string | null + provider?: string + }, + sharedContext: Context = {} + ): Promise { + const providerId = args?.provider ?? this.defaultProviderId + const provider = + this.providerService_.retrieveProviderRegistration(providerId) + + return await provider.release(keys, args, sharedContext) + } + + async releaseAll( + args?: { + ownerId?: string | null + provider?: string + }, + sharedContext: Context = {} + ): Promise { + const providerId = args?.provider ?? this.defaultProviderId + const provider = + this.providerService_.retrieveProviderRegistration(providerId) + + return await provider.releaseAll(args, sharedContext) + } +} diff --git a/packages/modules/locking/src/services/locking-provider.ts b/packages/modules/locking/src/services/locking-provider.ts new file mode 100644 index 0000000000..eca5769897 --- /dev/null +++ b/packages/modules/locking/src/services/locking-provider.ts @@ -0,0 +1,40 @@ +import { Constructor, ILockingProvider } from "@medusajs/framework/types" +import { MedusaError } from "@medusajs/framework/utils" +import { LockingProviderRegistrationPrefix } from "../types" + +type InjectedDependencies = { + [key: `lp_${string}`]: ILockingProvider +} + +export default class LockingProviderService { + protected __container__: InjectedDependencies + + constructor(container: InjectedDependencies) { + this.__container__ = container + } + + static getRegistrationIdentifier( + providerClass: Constructor + ) { + if (!(providerClass as any).identifier) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + `Trying to register a locking provider without an identifier.` + ) + } + return `${(providerClass as any).identifier}` + } + + public retrieveProviderRegistration(providerId: string): ILockingProvider { + try { + return this.__container__[ + `${LockingProviderRegistrationPrefix}${providerId}` + ] + } catch (err) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Could not find a locking provider with id: ${providerId}` + ) + } + } +} diff --git a/packages/modules/locking/src/types/index.ts b/packages/modules/locking/src/types/index.ts new file mode 100644 index 0000000000..9b66511bba --- /dev/null +++ b/packages/modules/locking/src/types/index.ts @@ -0,0 +1,33 @@ +import { + ModuleProviderExports, + ModuleServiceInitializeOptions, +} from "@medusajs/framework/types" + +export const LockingDefaultProvider = "default_provider" +export const LockingIdentifiersRegistrationName = "locking_providers_identifier" + +export const LockingProviderRegistrationPrefix = "lp_" + +export type LockingModuleOptions = Partial & { + /** + * Providers to be registered + */ + providers?: { + /** + * The module provider to be registered + */ + resolve: string | ModuleProviderExports + /** + * If the provider is the default + */ + is_default?: boolean + /** + * The id of the provider + */ + id: string + /** + * key value pair of the configuration to be passed to the provider constructor + */ + options?: Record + }[] +} diff --git a/packages/modules/locking/tsconfig.json b/packages/modules/locking/tsconfig.json new file mode 100644 index 0000000000..748a37ffa8 --- /dev/null +++ b/packages/modules/locking/tsconfig.json @@ -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"] + } + } +} diff --git a/yarn.lock b/yarn.lock index 8811001dc9..ddeee37397 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5652,6 +5652,31 @@ __metadata: languageName: unknown linkType: soft +"@medusajs/locking@^0.0.1, @medusajs/locking@workspace:packages/modules/locking": + version: 0.0.0-use.local + resolution: "@medusajs/locking@workspace:packages/modules/locking" + dependencies: + "@medusajs/framework": ^0.0.1 + "@mikro-orm/cli": 5.9.7 + "@mikro-orm/core": 5.9.7 + "@mikro-orm/migrations": 5.9.7 + "@mikro-orm/postgresql": 5.9.7 + "@swc/core": ^1.7.28 + "@swc/jest": ^0.2.36 + jest: ^29.7.0 + medusa-test-utils: ^1.1.44 + rimraf: ^3.0.2 + tsc-alias: ^1.8.6 + typescript: ^5.6.2 + peerDependencies: + "@medusajs/framework": ^0.0.1 + "@mikro-orm/core": 5.9.7 + "@mikro-orm/migrations": 5.9.7 + "@mikro-orm/postgresql": 5.9.7 + awilix: ^8.0.1 + languageName: unknown + linkType: soft + "@medusajs/medusa-cli@^1.3.22, @medusajs/medusa-cli@workspace:packages/cli/medusa-cli": version: 0.0.0-use.local resolution: "@medusajs/medusa-cli@workspace:packages/cli/medusa-cli" @@ -5761,6 +5786,7 @@ __metadata: "@medusajs/index": ^0.0.1 "@medusajs/inventory-next": ^0.0.3 "@medusajs/link-modules": ^0.2.11 + "@medusajs/locking": ^0.0.1 "@medusajs/notification": ^0.1.2 "@medusajs/notification-local": ^0.0.1 "@medusajs/notification-sendgrid": ^0.0.1