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