feat(providers): locking redis (#9544)

This commit is contained in:
Carlos R. L. Rodrigues
2024-10-15 12:40:24 -03:00
committed by GitHub
parent e77a2ff032
commit 4a03bdbb86
49 changed files with 1764 additions and 483 deletions

View File

@@ -33,6 +33,7 @@ packages/*
!packages/workflow-engine-inmemory
!packages/fulfillment
!packages/fulfillment-manual
!packages/locking-redis
!packages/index
!packages/framework

View File

@@ -134,6 +134,7 @@ module.exports = {
"./packages/modules/providers/file-s3/tsconfig.spec.json",
"./packages/modules/providers/fulfillment-manual/tsconfig.spec.json",
"./packages/modules/providers/payment-stripe/tsconfig.spec.json",
"./packages/modules/providers/locking-redis/tsconfig.spec.json",
"./packages/framework/tsconfig.json",
],

View File

@@ -62,6 +62,7 @@ module.exports = {
resolve: "@medusajs/cache-inmemory",
options: { ttl: 0 }, // Cache disabled
},
[Modules.LOCKING]: true,
[Modules.STOCK_LOCATION]: {
resolve: "@medusajs/stock-location-next",
options: {},

View File

@@ -1,6 +1,5 @@
import { IInventoryService } from "@medusajs/framework/types"
import { MathBN, Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { BigNumberInput } from "@medusajs/types"
export interface ReserveVariantInventoryStepInput {
@@ -22,34 +21,45 @@ export const reserveInventoryStepId = "reserve-inventory-step"
export const reserveInventoryStep = createStep(
reserveInventoryStepId,
async (data: ReserveVariantInventoryStepInput, { container }) => {
const inventoryService = container.resolve<IInventoryService>(
Modules.INVENTORY
)
const inventoryService = container.resolve(Modules.INVENTORY)
const items = data.items.map((item) => ({
const locking = container.resolve(Modules.LOCKING)
const inventoryItemIds: string[] = []
const items = data.items.map((item) => {
inventoryItemIds.push(item.inventory_item_id)
return {
line_item_id: item.id,
inventory_item_id: item.inventory_item_id,
quantity: MathBN.mult(item.required_quantity, item.quantity),
allow_backorder: item.allow_backorder,
location_id: item.location_ids[0],
}))
}
})
const reservations = await inventoryService.createReservationItems(items)
const reservations = await locking.execute(inventoryItemIds, async () => {
return await inventoryService.createReservationItems(items)
})
return new StepResponse(reservations, {
reservations: reservations.map((r) => r.id),
inventoryItemIds,
})
},
async (data, { container }) => {
if (!data) {
if (!data?.reservations?.length) {
return
}
const inventoryService = container.resolve<IInventoryService>(
Modules.INVENTORY
)
const inventoryService = container.resolve(Modules.INVENTORY)
const locking = container.resolve(Modules.LOCKING)
const inventoryItemIds = data.inventoryItemIds
await locking.execute(inventoryItemIds, async () => {
await inventoryService.deleteReservationItems(data.reservations)
})
return new StepResponse()
}

View File

@@ -1,5 +1,5 @@
import { IInventoryService, InventoryTypes } from "@medusajs/framework/types"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
import { InventoryTypes } from "@medusajs/framework/types"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { Modules } from "@medusajs/framework/utils"
@@ -10,22 +10,31 @@ export const createReservationsStepId = "create-reservations-step"
export const createReservationsStep = createStep(
createReservationsStepId,
async (data: InventoryTypes.CreateReservationItemInput[], { container }) => {
const service = container.resolve<IInventoryService>(Modules.INVENTORY)
const service = container.resolve(Modules.INVENTORY)
const locking = container.resolve(Modules.LOCKING)
const created = await service.createReservationItems(data)
const inventoryItemIds = data.map((item) => item.inventory_item_id)
return new StepResponse(
created,
created.map((reservation) => reservation.id)
)
const created = await locking.execute(inventoryItemIds, async () => {
return await service.createReservationItems(data)
})
return new StepResponse(created, {
reservations: created.map((reservation) => reservation.id),
inventoryItemIds: inventoryItemIds,
})
},
async (createdIds, { container }) => {
if (!createdIds?.length) {
async (data, { container }) => {
if (!data?.reservations?.length) {
return
}
const service = container.resolve<IInventoryService>(Modules.INVENTORY)
const service = container.resolve(Modules.INVENTORY)
const locking = container.resolve(Modules.LOCKING)
await service.deleteReservationItems(createdIds)
const inventoryItemIds = data.inventoryItemIds
await locking.execute(inventoryItemIds, async () => {
await service.deleteReservationItems(data.reservations)
})
}
)

View File

@@ -82,11 +82,11 @@ async function loadModule(
return
}
return await loadInternalModule(
return await loadInternalModule({
container,
resolution,
logger,
migrationOnly,
loaderOnly
)
loaderOnly,
})
}

View File

@@ -0,0 +1,11 @@
import { ModuleExports } from "@medusajs/types"
import { ModuleService } from "./services/module-service"
import { Module } from "@medusajs/utils"
const moduleExports: ModuleExports = {
service: ModuleService,
}
export * from "./services/module-service"
export default Module("module-with-providers", moduleExports)

View File

@@ -0,0 +1,8 @@
import { ModuleProviderService } from "./services/provider-service"
import { ModuleProvider } from "@medusajs/utils"
export * from "./services/provider-service"
export default ModuleProvider("provider-1", {
services: [ModuleProviderService],
})

View File

@@ -0,0 +1,3 @@
export class ModuleProviderService {
static identifier = "provider-1"
}

View File

@@ -0,0 +1,8 @@
import { ModuleProvider2Service } from "./services/provider-service"
import { ModuleProvider } from "@medusajs/utils"
export * from "./services/provider-service"
export default ModuleProvider("provider-2", {
services: [ModuleProvider2Service],
})

View File

@@ -0,0 +1 @@
export class ModuleProvider2Service {}

View File

@@ -0,0 +1,9 @@
import { InternalModuleDeclaration } from "@medusajs/types"
export class ModuleService {
constructor(
public container: Record<any, any>,
public moduleOptions: Record<any, any>,
public moduleDeclaration: InternalModuleDeclaration
) {}
}

View File

@@ -1,5 +1,5 @@
import { IModuleService, ModuleResolution } from "@medusajs/types"
import { upperCaseFirst } from "@medusajs/utils"
import { createMedusaContainer, upperCaseFirst } from "@medusajs/utils"
import { join } from "path"
import {
ModuleWithDmlMixedWithoutJoinerConfigFixtures,
@@ -7,9 +7,17 @@ import {
ModuleWithJoinerConfigFixtures,
ModuleWithoutJoinerConfigFixtures,
} from "../__fixtures__"
import { loadResources } from "../load-internal"
import {
getProviderRegistrationKey,
loadInternalModule,
loadResources,
} from "../load-internal"
import { ModuleProviderService as ModuleServiceWithProviderProvider1 } from "../__fixtures__/module-with-providers/provider-1"
import { ModuleProvider2Service as ModuleServiceWithProviderProvider2 } from "../__fixtures__/module-with-providers/provider-2"
import { ModuleService as ModuleServiceWithProvider } from "../__fixtures__/module-with-providers"
describe("load internal - load resources", () => {
describe("load internal", () => {
describe("loadResources", () => {
describe("when loading the module resources from a path", () => {
test("should return the correct resources and generate the correct joiner config from a mix of DML entities and mikro orm entities", async () => {
const { ModuleService, EntityModel, dmlEntity } =
@@ -327,4 +335,111 @@ describe("load internal - load resources", () => {
})
})
})
})
describe("loadInternalModule", () => {
test("should load the module and its providers using their identifier", async () => {
const moduleResolution: ModuleResolution = {
resolutionPath: join(
__dirname,
"../__fixtures__/module-with-providers"
),
moduleDeclaration: {
scope: "internal",
resources: "shared",
},
definition: {
key: "module-with-providers",
label: "Module with providers",
defaultPackage: false,
defaultModuleDeclaration: {
scope: "internal",
resources: "shared",
},
},
options: {
providers: [
{
resolve: join(
__dirname,
"../__fixtures__/module-with-providers/provider-1"
),
id: "provider-1-id",
options: {
api_key: "test",
},
},
],
},
}
const container = createMedusaContainer()
await loadInternalModule({
container: container,
resolution: moduleResolution,
logger: console as any,
})
const moduleService = container.resolve(moduleResolution.definition.key)
const provider = (moduleService as any).container[
getProviderRegistrationKey(
ModuleServiceWithProviderProvider1.identifier
)
]
expect(moduleService).toBeInstanceOf(ModuleServiceWithProvider)
expect(provider).toBeInstanceOf(ModuleServiceWithProviderProvider1)
})
test("should load the module and its providers using the provided id", async () => {
const moduleResolution: ModuleResolution = {
resolutionPath: join(
__dirname,
"../__fixtures__/module-with-providers"
),
moduleDeclaration: {
scope: "internal",
resources: "shared",
},
definition: {
key: "module-with-providers",
label: "Module with providers",
defaultPackage: false,
defaultModuleDeclaration: {
scope: "internal",
resources: "shared",
},
},
options: {
providers: [
{
resolve: join(
__dirname,
"../__fixtures__/module-with-providers/provider-2"
),
id: "provider-2-id",
options: {
api_key: "test",
},
},
],
},
}
const container = createMedusaContainer()
await loadInternalModule({
container: container,
resolution: moduleResolution,
logger: console as any,
})
const moduleService = container.resolve(moduleResolution.definition.key)
const provider = (moduleService as any).container[
getProviderRegistrationKey(moduleResolution.options!.providers![0].id)
]
expect(moduleService).toBeInstanceOf(ModuleServiceWithProvider)
expect(provider).toBeInstanceOf(ModuleServiceWithProviderProvider2)
})
})
})

View File

@@ -7,6 +7,9 @@ import {
MedusaContainer,
ModuleExports,
ModuleLoaderFunction,
ModuleProvider,
ModuleProviderExports,
ModuleProviderLoaderFunction,
ModuleResolution,
} from "@medusajs/types"
import {
@@ -15,6 +18,8 @@ import {
defineJoinerConfig,
DmlEntity,
dynamicImport,
isString,
MedusaModuleProviderType,
MedusaModuleType,
ModulesSdkUtils,
toMikroOrmEntities,
@@ -29,7 +34,7 @@ type ModuleResource = {
services: Function[]
models: Function[]
repositories: Function[]
loaders: ModuleLoaderFunction[]
loaders: ModuleLoaderFunction[] | ModuleProviderLoaderFunction[]
moduleService: Constructor<any>
normalizedPath: string
}
@@ -39,22 +44,36 @@ type MigrationFunction = (
moduleDeclaration?: InternalModuleDeclaration
) => Promise<void>
type ResolvedModule = ModuleExports & {
discoveryPath: string
}
type ResolvedModuleProvider = ModuleProviderExports & {
discoveryPath: string
}
export const moduleProviderRegistrationKeyPrefix = "__providers__"
/**
* Return the key used to register a module provider in the container
* @param {string} moduleKey
* @return {string}
*/
export function getProviderRegistrationKey(moduleKey: string): string {
return moduleProviderRegistrationKeyPrefix + moduleKey
}
export async function resolveModuleExports({
resolution,
}: {
resolution: ModuleResolution
}): Promise<
| (ModuleExports & {
discoveryPath: string
})
| { error: any }
> {
}): Promise<ResolvedModule | ResolvedModuleProvider | { error: any }> {
let resolvedModuleExports: ModuleExports
try {
if (resolution.moduleExports) {
// TODO:
// If we want to benefit from the auto load mechanism, even if the module exports is provided, we need to ask for the module path
resolvedModuleExports = resolution.moduleExports
resolvedModuleExports = resolution.moduleExports as ModuleExports
resolvedModuleExports.discoveryPath = resolution.resolutionPath as string
} else {
const module = await dynamicImport(resolution.resolutionPath as string)
@@ -62,10 +81,12 @@ export async function resolveModuleExports({
if ("discoveryPath" in module) {
const reExportedLoadedModule = await dynamicImport(module.discoveryPath)
const discoveryPath = module.discoveryPath
resolvedModuleExports = reExportedLoadedModule.default
resolvedModuleExports =
reExportedLoadedModule.default ?? reExportedLoadedModule
resolvedModuleExports.discoveryPath = discoveryPath as string
} else {
resolvedModuleExports = (module as { default: ModuleExports }).default
resolvedModuleExports =
(module as { default: ModuleExports }).default ?? module
resolvedModuleExports.discoveryPath =
resolution.resolutionPath as string
}
@@ -90,13 +111,79 @@ export async function resolveModuleExports({
}
}
export async function loadInternalModule(
container: MedusaContainer,
resolution: ModuleResolution,
logger: Logger,
migrationOnly?: boolean,
async function loadInternalProvider(
args: {
container: MedusaContainer
resolution: ModuleResolution
logger: Logger
migrationOnly?: boolean
loaderOnly?: boolean
},
providers: ModuleProvider[]
): Promise<{ error?: Error } | void> {
const { container, resolution, logger, migrationOnly } = args
const errors: { error?: Error }[] = []
for (const provider of providers) {
const providerRes = provider.resolve as ModuleProviderExports
const canLoadProvider =
providerRes && (isString(providerRes) || !providerRes?.services)
if (!canLoadProvider) {
continue
}
const res = await loadInternalModule({
container,
resolution: {
...resolution,
moduleExports: !isString(providerRes) ? providerRes : undefined,
definition: {
...resolution.definition,
key: provider.id,
},
resolutionPath: isString(provider.resolve) ? provider.resolve : false,
},
logger,
migrationOnly,
loadingProviders: true,
})
if (res) {
errors.push(res)
}
}
const errorMessages = errors.map((e) => e.error?.message).join("\n")
return errors.length
? {
error: {
name: "ModuleProviderError",
message: `Errors while loading module providers for module ${resolution.definition.key}:\n${errorMessages}`,
stack: errors.map((e) => e.error?.stack).join("\n"),
},
}
: undefined
}
export async function loadInternalModule(args: {
container: MedusaContainer
resolution: ModuleResolution
logger: Logger
migrationOnly?: boolean
loaderOnly?: boolean
loadingProviders?: boolean
}): Promise<{ error?: Error } | void> {
const {
container,
resolution,
logger,
migrationOnly,
loaderOnly,
loadingProviders,
} = args
const keyName = !loaderOnly
? resolution.definition.key
: resolution.definition.key + "__loaderOnly"
@@ -121,7 +208,12 @@ export async function loadInternalModule(
})
}
if (!loadedModule?.service && !moduleResources.moduleService) {
const loadedModule_ = loadedModule as ModuleExports
if (
!loadingProviders &&
!loadedModule_?.service &&
!moduleResources.moduleService
) {
container.register({
[keyName]: asValue(undefined),
})
@@ -133,20 +225,6 @@ export async function loadInternalModule(
}
}
if (migrationOnly) {
const moduleService_ = moduleResources.moduleService ?? loadedModule.service
// Partially loaded module, only register the service __joinerConfig function to be able to resolve it later
const moduleService = {
__joinerConfig: moduleService_.prototype.__joinerConfig,
}
container.register({
[keyName]: asValue(moduleService),
})
return
}
const localContainer = createMedusaContainer()
const dependencies = resolution?.dependencies ?? []
@@ -177,6 +255,44 @@ export async function loadInternalModule(
)
}
// if module has providers, load them
let providerOptions: any = undefined
if (!loadingProviders) {
const providers = (resolution?.options?.providers as any[]) ?? []
const res = await loadInternalProvider(
{
...args,
container: localContainer,
},
providers
)
if (res?.error) {
return res
}
} else {
providerOptions = (resolution?.options?.providers as any[]).find(
(p) => p.id === resolution.definition.key
)?.options
}
if (migrationOnly && !loadingProviders) {
const moduleService_ =
moduleResources.moduleService ?? loadedModule_.service
// Partially loaded module, only register the service __joinerConfig function to be able to resolve it later
const moduleService = {
__joinerConfig: moduleService_.prototype.__joinerConfig,
}
container.register({
[keyName]: asValue(moduleService),
})
return
}
const loaders = moduleResources.loaders ?? loadedModule?.loaders ?? []
const error = await runLoaders(loaders, {
container,
@@ -185,14 +301,45 @@ export async function loadInternalModule(
resolution,
loaderOnly,
keyName,
providerOptions,
})
if (error) {
return error
}
const moduleService = moduleResources.moduleService ?? loadedModule.service
if (loadingProviders) {
const loadedProvider_ = loadedModule as ModuleProviderExports
let moduleProviderServices = moduleResources.moduleService
? [moduleResources.moduleService]
: loadedProvider_.services ?? loadedProvider_
if (!moduleProviderServices) {
return
}
for (const moduleProviderService of moduleProviderServices) {
const modProvider_ = moduleProviderService as any
modProvider_.identifier ??= keyName
modProvider_.__type = MedusaModuleProviderType
const registrationKey = getProviderRegistrationKey(
modProvider_.identifier
)
container.register({
[registrationKey]: asFunction((cradle) => {
;(moduleProviderService as any).__type = MedusaModuleType
return new moduleProviderService(
localContainer.cradle,
resolution.options,
resolution.moduleDeclaration
)
}).singleton(),
})
}
} else {
const moduleService = moduleResources.moduleService ?? loadedModule_.service
container.register({
[keyName]: asFunction((cradle) => {
;(moduleService as any).__type = MedusaModuleType
@@ -203,6 +350,7 @@ export async function loadInternalModule(
)
}).singleton(),
})
}
if (loaderOnly) {
// The expectation is only to run the loader as standalone, so we do not need to register the service and we need to cleanup all services
@@ -220,46 +368,114 @@ export async function loadModuleMigrations(
revertMigration?: MigrationFunction
generateMigration?: MigrationFunction
}> {
const loadedModule = await resolveModuleExports({
const mainLoadedModule = await resolveModuleExports({
resolution: { ...resolution, moduleExports },
})
if ("error" in loadedModule) {
throw loadedModule.error
const loadedServices = [mainLoadedModule] as (
| ResolvedModule
| ResolvedModuleProvider
)[]
if (Array.isArray(resolution?.options?.providers)) {
for (const provider of (resolution.options as any).providers) {
const providerRes = provider.resolve as ModuleProviderExports
const canLoadProvider =
providerRes && (isString(providerRes) || !providerRes?.services)
if (!canLoadProvider) {
continue
}
try {
let runMigrations = loadedModule.runMigrations
let revertMigration = loadedModule.revertMigration
let generateMigration = loadedModule.generateMigration
const loadedProvider = await resolveModuleExports({
resolution: {
...resolution,
moduleExports: !isString(providerRes) ? providerRes : undefined,
definition: {
...resolution.definition,
key: provider.id,
},
resolutionPath: isString(provider.resolve) ? provider.resolve : false,
},
})
loadedServices.push(loadedProvider as ResolvedModuleProvider)
}
}
if (!runMigrations || !revertMigration) {
if ("error" in mainLoadedModule) {
throw mainLoadedModule.error
}
const runMigrationsFn: ((...args) => Promise<any>)[] = []
const revertMigrationFn: ((...args) => Promise<any>)[] = []
const generateMigrationFn: ((...args) => Promise<any>)[] = []
try {
const migrationScripts: any[] = []
for (const loadedModule of loadedServices) {
let runMigrationsCustom = loadedModule.runMigrations
let revertMigrationCustom = loadedModule.revertMigration
let generateMigrationCustom = loadedModule.generateMigration
runMigrationsCustom && runMigrationsFn.push(runMigrationsCustom)
revertMigrationCustom && revertMigrationFn.push(revertMigrationCustom)
generateMigrationCustom &&
generateMigrationFn.push(generateMigrationCustom)
if (!runMigrationsCustom || !revertMigrationCustom) {
const moduleResources = await loadResources({
moduleResolution: resolution,
discoveryPath: loadedModule.discoveryPath,
loadedModuleLoaders: loadedModule?.loaders,
})
const migrationScriptOptions = {
migrationScripts.push({
moduleName: resolution.definition.key,
models: moduleResources.models,
pathToMigrations: join(moduleResources.normalizedPath, "migrations"),
})
}
runMigrations ??= ModulesSdkUtils.buildMigrationScript(
migrationScriptOptions
)
for (const migrationScriptOptions of migrationScripts) {
const migrationUp =
runMigrationsCustom ??
ModulesSdkUtils.buildMigrationScript(migrationScriptOptions)
runMigrationsFn.push(migrationUp)
revertMigration ??= ModulesSdkUtils.buildRevertMigrationScript(
migrationScriptOptions
)
const migrationDown =
revertMigrationCustom ??
ModulesSdkUtils.buildRevertMigrationScript(migrationScriptOptions)
revertMigrationFn.push(migrationDown)
generateMigration ??= ModulesSdkUtils.buildGenerateMigrationScript(
migrationScriptOptions
)
const genMigration =
generateMigrationCustom ??
ModulesSdkUtils.buildGenerateMigrationScript(migrationScriptOptions)
generateMigrationFn.push(genMigration)
}
}
return { runMigrations, revertMigration, generateMigration }
const runMigrations = async (...args) => {
for (const migration of runMigrationsFn.filter(Boolean)) {
await migration.apply(migration, args)
}
}
const revertMigration = async (...args) => {
for (const migration of revertMigrationFn.filter(Boolean)) {
await migration.apply(migration, args)
}
}
const generateMigration = async (...args) => {
for (const migration of generateMigrationFn.filter(Boolean)) {
await migration.apply(migration, args)
}
}
return {
runMigrations,
revertMigration,
generateMigration,
}
} catch {
return {}
}
@@ -308,7 +524,7 @@ export async function loadResources({
moduleResolution: ModuleResolution
discoveryPath: string
logger?: Logger
loadedModuleLoaders?: ModuleLoaderFunction[]
loadedModuleLoaders?: ModuleLoaderFunction[] | ModuleProviderLoaderFunction[]
}): Promise<ModuleResource> {
logger ??= console as unknown as Logger
loadedModuleLoaders ??= []
@@ -324,7 +540,8 @@ export async function loadResources({
const [moduleService, services, models, repositories] = await Promise.all([
dynamicImport(modulePath).then((moduleExports) => {
return moduleExports.default.service
const mod = moduleExports.default ?? moduleExports
return mod.service
}),
importAllFromDir(resolve(normalizedPath, "services")).catch(
defaultOnFail
@@ -365,11 +582,14 @@ export async function loadResources({
migrationPath: normalizedPath + "/migrations",
})
// if a module service is provided, we generate a joiner config
if (moduleService) {
generateJoinerConfigIfNecessary({
moduleResolution,
service: moduleService,
models: potentialModels,
})
}
return {
services: potentialServices,
@@ -390,7 +610,15 @@ export async function loadResources({
async function runLoaders(
loaders: Function[] = [],
{ localContainer, container, logger, resolution, loaderOnly, keyName }
{
localContainer,
container,
logger,
resolution,
loaderOnly,
keyName,
providerOptions,
}
): Promise<void | { error: Error }> {
try {
for (const loader of loaders) {
@@ -398,8 +626,9 @@ async function runLoaders(
{
container: localContainer,
logger,
options: resolution.options,
options: providerOptions ?? resolution.options,
dataLoaderOnly: loaderOnly,
moduleOptions: providerOptions ? resolution.options : undefined,
},
resolution.moduleDeclaration as InternalModuleDeclaration
)
@@ -418,14 +647,17 @@ async function runLoaders(
}
function prepareLoaders({
loadedModuleLoaders = [] as ModuleLoaderFunction[],
loadedModuleLoaders = [] as
| ModuleLoaderFunction[]
| ModuleProviderLoaderFunction[],
models,
repositories,
services,
moduleResolution,
migrationPath,
}) {
const finalLoaders: ModuleLoaderFunction[] = []
const finalLoaders: (ModuleLoaderFunction | ModuleProviderLoaderFunction)[] =
[]
const toObjectReducer = (acc, curr) => {
acc[curr.name] = curr

View File

@@ -510,7 +510,7 @@ async function MedusaApp_({
modulePath: moduleResolution.resolutionPath as string,
container: sharedContainer,
options: moduleResolution.options,
moduleExports: moduleResolution.moduleExports,
moduleExports: moduleResolution.moduleExports as ModuleExports,
}
if (action === "revert") {

View File

@@ -3,6 +3,7 @@ import { JoinerRelationship, JoinerServiceConfig } from "../joiner"
import { MedusaContainer } from "../common"
import { RepositoryService } from "../dal"
import { Logger } from "../logger"
import { ModuleProviderExports } from "./module-provider"
import {
RemoteQueryGraph,
RemoteQueryInput,
@@ -86,7 +87,7 @@ export type ModuleResolution = {
options?: Record<string, unknown>
dependencies?: string[]
moduleDeclaration?: InternalModuleDeclaration | ExternalModuleDeclaration
moduleExports?: ModuleExports
moduleExports?: ModuleExports | ModuleProviderExports
}
export type ModuleDefinition = {

View File

@@ -1,11 +1,47 @@
import { Constructor } from "./index"
import { Logger } from "../logger"
import {
Constructor,
InternalModuleDeclaration,
MedusaContainer,
} from "./index"
export type ModuleProviderExports = {
services: Constructor<any>[]
export type ProviderLoaderOptions<TOptions = Record<string, unknown>> = {
container: MedusaContainer
options?: TOptions
logger?: Logger
moduleOptions: Record<string, unknown>
}
export type ModuleProviderExports<Service = any> = {
module?: string
services: Constructor<Service>[]
loaders?: ModuleProviderLoaderFunction[]
runMigrations?(
options: ProviderLoaderOptions<Service>,
moduleDeclaration?: any
): Promise<void>
revertMigration?(
options: ProviderLoaderOptions<Service>,
moduleDeclaration?: any
): Promise<void>
generateMigration?(
options: ProviderLoaderOptions<Service>,
moduleDeclaration?: any
): Promise<void>
/**
* Explicitly set the the true location of the module resources.
* Can be used to re-export the module from a different location and specify its original location.
*/
discoveryPath?: string
}
export type ModuleProviderLoaderFunction = (
options: ProviderLoaderOptions,
moduleDeclaration?: InternalModuleDeclaration
) => Promise<void>
export type ModuleProvider = {
resolve: string | ModuleProviderExports
resolve: string | ModuleProviderExports<any>
id: string
options?: Record<string, unknown>
is_default?: boolean

View File

@@ -66,6 +66,9 @@ describe("defineConfig", function () {
"inventory": {
"resolve": "@medusajs/medusa/inventory-next",
},
"locking": {
"resolve": "@medusajs/medusa/locking",
},
"notification": {
"options": {
"providers": [
@@ -213,6 +216,9 @@ describe("defineConfig", function () {
"inventory": {
"resolve": "@medusajs/medusa/inventory-next",
},
"locking": {
"resolve": "@medusajs/medusa/locking",
},
"notification": {
"options": {
"providers": [
@@ -368,6 +374,9 @@ describe("defineConfig", function () {
"inventory": {
"resolve": "@medusajs/medusa/inventory-next",
},
"locking": {
"resolve": "@medusajs/medusa/locking",
},
"notification": {
"options": {
"providers": [
@@ -524,6 +533,9 @@ describe("defineConfig", function () {
"inventory": {
"resolve": "@medusajs/medusa/inventory-next",
},
"locking": {
"resolve": "@medusajs/medusa/locking",
},
"notification": {
"options": {
"providers": [
@@ -668,6 +680,9 @@ describe("defineConfig", function () {
"inventory": {
"resolve": "@medusajs/medusa/inventory-next",
},
"locking": {
"resolve": "@medusajs/medusa/locking",
},
"notification": {
"options": {
"providers": [
@@ -812,6 +827,9 @@ describe("defineConfig", function () {
"inventory": {
"resolve": "@medusajs/medusa/inventory-next",
},
"locking": {
"resolve": "@medusajs/medusa/locking",
},
"notification": {
"options": {
"providers": [

View File

@@ -8,10 +8,10 @@ import {
Modules,
REVERSED_MODULE_PACKAGE_NAMES,
} from "../modules-sdk"
import { isString } from "./is-string"
import { resolveExports } from "./resolve-exports"
import { isObject } from "./is-object"
import { isString } from "./is-string"
import { normalizeImportPathWithSource } from "./normalize-import-path-with-source"
import { resolveExports } from "./resolve-exports"
const DEFAULT_SECRET = "supersecret"
const DEFAULT_ADMIN_URL = "http://localhost:9000"
@@ -132,6 +132,7 @@ function resolveModules(
{ resolve: MODULE_PACKAGE_NAMES[Modules.CACHE] },
{ resolve: MODULE_PACKAGE_NAMES[Modules.EVENT_BUS] },
{ resolve: MODULE_PACKAGE_NAMES[Modules.WORKFLOW_ENGINE] },
{ resolve: MODULE_PACKAGE_NAMES[Modules.LOCKING] },
{ resolve: MODULE_PACKAGE_NAMES[Modules.STOCK_LOCATION] },
{ resolve: MODULE_PACKAGE_NAMES[Modules.INVENTORY] },
{ resolve: MODULE_PACKAGE_NAMES[Modules.PRODUCT] },

View File

@@ -1,4 +1,3 @@
export * from "./graphql"
export * from "./api-key"
export * from "./auth"
export * from "./bundles"
@@ -12,6 +11,7 @@ export * from "./exceptions"
export * from "./feature-flags"
export * from "./file"
export * from "./fulfillment"
export * from "./graphql"
export * from "./inventory"
export * from "./link"
export * from "./modules-sdk"
@@ -30,3 +30,4 @@ export * from "./totals/big-number"
export * from "./user"
export const MedusaModuleType = Symbol.for("MedusaModule")
export const MedusaModuleProviderType = Symbol.for("MedusaModuleProvider")

View File

@@ -15,6 +15,7 @@ export * from "./medusa-service"
export * from "./migration-scripts"
export * from "./mikro-orm-cli-config-builder"
export * from "./module"
export * from "./module-provider"
export * from "./query-context"
export * from "./types/links-config"
export * from "./types/medusa-service"

View File

@@ -0,0 +1,19 @@
import { ModuleProviderExports } from "@medusajs/types"
/**
* Wrapper to build the module provider export
*
* @param serviceName // The name of the module the provider is for
* @param services // The array of services that the module provides
* @param loaders // The loaders that the module provider provides
*/
export function ModuleProvider(
serviceName: string,
{ services, loaders }: ModuleProviderExports
): ModuleProviderExports {
return {
module: serviceName,
services,
loaders,
}
}

View File

@@ -84,6 +84,7 @@
"@medusajs/inventory-next": "^0.0.3",
"@medusajs/link-modules": "^0.2.11",
"@medusajs/locking": "^0.0.1",
"@medusajs/locking-redis": "^0.0.1",
"@medusajs/notification": "^0.1.2",
"@medusajs/notification-local": "^0.0.1",
"@medusajs/notification-sendgrid": "^0.0.1",

View File

@@ -0,0 +1,6 @@
import RedisLockingProvider from "@medusajs/locking-redis"
export * from "@medusajs/locking-redis"
export default RedisLockingProvider
export const discoveryPath = require.resolve("@medusajs/locking-redis")

View File

@@ -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")
}
/**

View File

@@ -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)
})
},
})

View File

@@ -8,6 +8,12 @@
"url": "https://github.com/medusajs/medusa",
"directory": "packages/locking"
},
"files": [
"dist",
"!dist/**/__tests__",
"!dist/**/__mocks__",
"!dist/**/__fixtures__"
],
"publishConfig": {
"access": "public"
},

View File

@@ -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,

View File

@@ -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))

View File

@@ -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, {
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)
})

View File

@@ -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
})

View File

@@ -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
})

View File

@@ -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
})

View File

@@ -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
})

View File

@@ -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
})

View File

@@ -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
})

View File

@@ -0,0 +1,4 @@
dist
node_modules
.DS_store
yarn.lock

View File

@@ -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)
})
},
})

View File

@@ -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",
},
})

View File

@@ -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"
]
}

View File

@@ -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,
})

View File

@@ -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:"),
})
}

View File

@@ -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."))
})
}
}

View File

@@ -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
}

View File

@@ -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"]
}
}
}

View File

@@ -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
})

View File

@@ -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
})

View File

@@ -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
})

View File

@@ -5738,6 +5738,22 @@ __metadata:
languageName: unknown
linkType: soft
"@medusajs/locking-redis@^0.0.1, @medusajs/locking-redis@workspace:packages/modules/providers/locking-redis":
version: 0.0.0-use.local
resolution: "@medusajs/locking-redis@workspace:packages/modules/providers/locking-redis"
dependencies:
"@medusajs/framework": ^0.0.1
"@swc/core": ^1.7.28
"@swc/jest": ^0.2.36
ioredis: ^5.4.1
jest: ^29.7.0
rimraf: ^5.0.1
typescript: ^5.6.2
peerDependencies:
"@medusajs/framework": ^0.0.1
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"
@@ -5873,6 +5889,7 @@ __metadata:
"@medusajs/inventory-next": ^0.0.3
"@medusajs/link-modules": ^0.2.11
"@medusajs/locking": ^0.0.1
"@medusajs/locking-redis": ^0.0.1
"@medusajs/notification": ^0.1.2
"@medusajs/notification-local": ^0.0.1
"@medusajs/notification-sendgrid": ^0.0.1