feat: Workflow engine modules (#6128)
This commit is contained in:
1 parent
d85fee42ee
commit
302323916b
119 files changed
+5334
-258
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/workflow-engine-inmemory": patch
|
||||||
|
"@medusajs/workflow-engine-redis": patch
|
||||||
|
"@medusajs/orchestration": patch
|
||||||
|
"@medusajs/workflows-sdk": patch
|
||||||
|
"@medusajs/modules-sdk": patch
|
||||||
|
"@medusajs/types": patch
|
||||||
|
"@medusajs/utils": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Modules: Workflows Engine in-memory and Redis
|
||||||
@@ -24,6 +24,9 @@ packages/*
|
|||||||
!packages/orchestration
|
!packages/orchestration
|
||||||
!packages/workflows-sdk
|
!packages/workflows-sdk
|
||||||
!packages/core-flows
|
!packages/core-flows
|
||||||
|
!packages/workflow-engine-redis
|
||||||
|
!packages/workflow-engine-inmemory
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**/models/*
|
**/models/*
|
||||||
|
|||||||
+3
-3
@@ -72,9 +72,7 @@ module.exports = {
|
|||||||
node: true,
|
node: true,
|
||||||
jest: true,
|
jest: true,
|
||||||
},
|
},
|
||||||
ignorePatterns: [
|
ignorePatterns: ["packages/admin-next/dashboard/**/dist"],
|
||||||
"packages/admin-next/dashboard/**/dist"
|
|
||||||
],
|
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: ["*.ts"],
|
files: ["*.ts"],
|
||||||
@@ -101,6 +99,8 @@ module.exports = {
|
|||||||
"./packages/orchestration/tsconfig.json",
|
"./packages/orchestration/tsconfig.json",
|
||||||
"./packages/workflows-sdk/tsconfig.spec.json",
|
"./packages/workflows-sdk/tsconfig.spec.json",
|
||||||
"./packages/core-flows/tsconfig.spec.json",
|
"./packages/core-flows/tsconfig.spec.json",
|
||||||
|
"./packages/workflow-engine-redis/tsconfig.spec.json",
|
||||||
|
"./packages/workflow-engine-inmemory/tsconfig.spec.json",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
+6
-6
@@ -65,15 +65,15 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"hooks:install": "husky install",
|
"hooks:install": "husky install",
|
||||||
"hooks:uninstall": "husky uninstall",
|
"hooks:uninstall": "husky uninstall",
|
||||||
"build": "turbo run build --no-daemon",
|
"build": "turbo run build --concurrency=50% --no-daemon",
|
||||||
"lint": "eslint --ignore-path .eslintignore --ext .js,.ts,.tsx .",
|
"lint": "eslint --ignore-path .eslintignore --ext .js,.ts,.tsx .",
|
||||||
"prettier": "prettier",
|
"prettier": "prettier",
|
||||||
"jest": "jest",
|
"jest": "jest",
|
||||||
"test": "turbo run test --no-daemon",
|
"test": "turbo run test --concurrency=50% --no-daemon",
|
||||||
"test:integration:packages": "turbo run test:integration --no-daemon --filter='./packages/*'",
|
"test:integration:packages": "turbo run test:integration --concurrency=1 --no-daemon --filter='./packages/*'",
|
||||||
"test:integration:api": "turbo run test:integration --no-daemon --filter=integration-tests-api",
|
"test:integration:api": "turbo run test:integration --concurrency=50% --no-daemon --filter=integration-tests-api",
|
||||||
"test:integration:plugins": "turbo run test:integration --no-daemon --filter=integration-tests-plugins",
|
"test:integration:plugins": "turbo run test:integration --concurrency=50% --no-daemon --filter=integration-tests-plugins",
|
||||||
"test:integration:repositories": "turbo run test:integration --no-daemon --filter=integration-tests-repositories",
|
"test:integration:repositories": "turbo run test:integration --concurrency=50% --no-daemon --filter=integration-tests-repositories",
|
||||||
"openapi:generate": "yarn ./packages/oas/oas-github-ci run ci --with-full-file",
|
"openapi:generate": "yarn ./packages/oas/oas-github-ci run ci --with-full-file",
|
||||||
"medusa-oas": "yarn ./packages/oas/medusa-oas-cli run medusa-oas",
|
"medusa-oas": "yarn ./packages/oas/medusa-oas-cli run medusa-oas",
|
||||||
"release:snapshot": "changeset publish --no-git-tags --snapshot --tag snapshot",
|
"release:snapshot": "changeset publish --no-git-tags --snapshot --tag snapshot",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import cors from "cors"
|
import cors from "cors"
|
||||||
import { Router } from "express"
|
import { Router } from "express"
|
||||||
|
import { parseCorsOrigins } from "medusa-core-utils"
|
||||||
import middlewares from "../../middlewares"
|
import middlewares from "../../middlewares"
|
||||||
import analyticsConfigs from "./analytics-configs"
|
import analyticsConfigs from "./analytics-configs"
|
||||||
import appRoutes from "./apps"
|
import appRoutes from "./apps"
|
||||||
@@ -18,16 +19,18 @@ import noteRoutes from "./notes"
|
|||||||
import notificationRoutes from "./notifications"
|
import notificationRoutes from "./notifications"
|
||||||
import orderEditRoutes from "./order-edits"
|
import orderEditRoutes from "./order-edits"
|
||||||
import orderRoutes from "./orders"
|
import orderRoutes from "./orders"
|
||||||
|
import paymentCollectionRoutes from "./payment-collections"
|
||||||
|
import paymentRoutes from "./payments"
|
||||||
import priceListRoutes from "./price-lists"
|
import priceListRoutes from "./price-lists"
|
||||||
|
import productCategoryRoutes from "./product-categories"
|
||||||
import productTagRoutes from "./product-tags"
|
import productTagRoutes from "./product-tags"
|
||||||
import productTypesRoutes from "./product-types"
|
import productTypesRoutes from "./product-types"
|
||||||
import publishableApiKeyRoutes from "./publishable-api-keys"
|
|
||||||
import productRoutes from "./products"
|
import productRoutes from "./products"
|
||||||
|
import publishableApiKeyRoutes from "./publishable-api-keys"
|
||||||
import regionRoutes from "./regions"
|
import regionRoutes from "./regions"
|
||||||
import reservationRoutes from "./reservations"
|
import reservationRoutes from "./reservations"
|
||||||
import returnReasonRoutes from "./return-reasons"
|
import returnReasonRoutes from "./return-reasons"
|
||||||
import returnRoutes from "./returns"
|
import returnRoutes from "./returns"
|
||||||
import reservationRoutes from "./reservations"
|
|
||||||
import salesChannelRoutes from "./sales-channels"
|
import salesChannelRoutes from "./sales-channels"
|
||||||
import shippingOptionRoutes from "./shipping-options"
|
import shippingOptionRoutes from "./shipping-options"
|
||||||
import shippingProfileRoutes from "./shipping-profiles"
|
import shippingProfileRoutes from "./shipping-profiles"
|
||||||
@@ -38,10 +41,6 @@ import taxRateRoutes from "./tax-rates"
|
|||||||
import uploadRoutes from "./uploads"
|
import uploadRoutes from "./uploads"
|
||||||
import userRoutes, { unauthenticatedUserRoutes } from "./users"
|
import userRoutes, { unauthenticatedUserRoutes } from "./users"
|
||||||
import variantRoutes from "./variants"
|
import variantRoutes from "./variants"
|
||||||
import paymentCollectionRoutes from "./payment-collections"
|
|
||||||
import paymentRoutes from "./payments"
|
|
||||||
import productCategoryRoutes from "./product-categories"
|
|
||||||
import { parseCorsOrigins } from "medusa-core-utils"
|
|
||||||
|
|
||||||
const route = Router()
|
const route = Router()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ModulesDefinition,
|
ModulesDefinition,
|
||||||
registerMedusaModule,
|
registerMedusaModule,
|
||||||
} from "@medusajs/modules-sdk"
|
} from "@medusajs/modules-sdk"
|
||||||
|
import { ContainerRegistrationKeys } from "@medusajs/utils"
|
||||||
import { asValue, createContainer } from "awilix"
|
import { asValue, createContainer } from "awilix"
|
||||||
import express from "express"
|
import express from "express"
|
||||||
import jwt from "jsonwebtoken"
|
import jwt from "jsonwebtoken"
|
||||||
@@ -63,6 +64,7 @@ export const createServer = async (rootDir) => {
|
|||||||
return this
|
return this
|
||||||
}.bind(container)
|
}.bind(container)
|
||||||
|
|
||||||
|
container.register(ContainerRegistrationKeys.PG_CONNECTION, asValue({}))
|
||||||
container.register("featureFlagRouter", asValue(featureFlagRouter))
|
container.register("featureFlagRouter", asValue(featureFlagRouter))
|
||||||
container.register("configModule", asValue(config))
|
container.register("configModule", asValue(config))
|
||||||
container.register({
|
container.register({
|
||||||
|
|||||||
Binary file not shown.
@@ -16,6 +16,7 @@ export enum Modules {
|
|||||||
PRICING = "pricingService",
|
PRICING = "pricingService",
|
||||||
PROMOTION = "promotion",
|
PROMOTION = "promotion",
|
||||||
AUTHENTICATION = "authentication",
|
AUTHENTICATION = "authentication",
|
||||||
|
WORKFLOW_ENGINE = "workflows",
|
||||||
CART = "cart",
|
CART = "cart",
|
||||||
CUSTOMER = "customer",
|
CUSTOMER = "customer",
|
||||||
PAYMENT = "payment",
|
PAYMENT = "payment",
|
||||||
@@ -30,6 +31,7 @@ export enum ModuleRegistrationName {
|
|||||||
PRICING = "pricingModuleService",
|
PRICING = "pricingModuleService",
|
||||||
PROMOTION = "promotionModuleService",
|
PROMOTION = "promotionModuleService",
|
||||||
AUTHENTICATION = "authenticationModuleService",
|
AUTHENTICATION = "authenticationModuleService",
|
||||||
|
WORKFLOW_ENGINE = "workflowsModuleService",
|
||||||
CART = "cartModuleService",
|
CART = "cartModuleService",
|
||||||
CUSTOMER = "customerModuleService",
|
CUSTOMER = "customerModuleService",
|
||||||
PAYMENT = "paymentModuleService",
|
PAYMENT = "paymentModuleService",
|
||||||
@@ -45,6 +47,7 @@ export const MODULE_PACKAGE_NAMES = {
|
|||||||
[Modules.PRICING]: "@medusajs/pricing",
|
[Modules.PRICING]: "@medusajs/pricing",
|
||||||
[Modules.PROMOTION]: "@medusajs/promotion",
|
[Modules.PROMOTION]: "@medusajs/promotion",
|
||||||
[Modules.AUTHENTICATION]: "@medusajs/authentication",
|
[Modules.AUTHENTICATION]: "@medusajs/authentication",
|
||||||
|
[Modules.WORKFLOW_ENGINE]: "@medusajs/workflow-engine-inmemory",
|
||||||
[Modules.CART]: "@medusajs/cart",
|
[Modules.CART]: "@medusajs/cart",
|
||||||
[Modules.CUSTOMER]: "@medusajs/customer",
|
[Modules.CUSTOMER]: "@medusajs/customer",
|
||||||
[Modules.PAYMENT]: "@medusajs/payment",
|
[Modules.PAYMENT]: "@medusajs/payment",
|
||||||
@@ -165,6 +168,20 @@ export const ModulesDefinition: { [key: string | Modules]: ModuleDefinition } =
|
|||||||
resources: MODULE_RESOURCE_TYPE.SHARED,
|
resources: MODULE_RESOURCE_TYPE.SHARED,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
[Modules.WORKFLOW_ENGINE]: {
|
||||||
|
key: Modules.WORKFLOW_ENGINE,
|
||||||
|
registrationName: ModuleRegistrationName.WORKFLOW_ENGINE,
|
||||||
|
defaultPackage: false,
|
||||||
|
label: upperCaseFirst(ModuleRegistrationName.WORKFLOW_ENGINE),
|
||||||
|
isRequired: false,
|
||||||
|
canOverride: true,
|
||||||
|
isQueryable: true,
|
||||||
|
dependencies: ["logger"],
|
||||||
|
defaultModuleDeclaration: {
|
||||||
|
scope: MODULE_SCOPE.INTERNAL,
|
||||||
|
resources: MODULE_RESOURCE_TYPE.SHARED,
|
||||||
|
},
|
||||||
|
},
|
||||||
[Modules.CART]: {
|
[Modules.CART]: {
|
||||||
key: Modules.CART,
|
key: Modules.CART,
|
||||||
registrationName: ModuleRegistrationName.CART,
|
registrationName: ModuleRegistrationName.CART,
|
||||||
|
|||||||
@@ -245,6 +245,12 @@ export async function MedusaApp({
|
|||||||
registerCustomJoinerConfigs(servicesConfig ?? [])
|
registerCustomJoinerConfigs(servicesConfig ?? [])
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
sharedResourcesConfig?.database?.connection &&
|
||||||
|
!injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION]
|
||||||
|
) {
|
||||||
|
injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION] =
|
||||||
|
sharedResourcesConfig.database.connection
|
||||||
|
} else if (
|
||||||
dbData.clientUrl &&
|
dbData.clientUrl &&
|
||||||
!injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION]
|
!injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION]
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import { TransactionStepState, TransactionStepStatus } from "@medusajs/utils"
|
||||||
|
import { setTimeout } from "timers/promises"
|
||||||
import {
|
import {
|
||||||
DistributedTransaction,
|
DistributedTransaction,
|
||||||
TransactionHandlerType,
|
TransactionHandlerType,
|
||||||
TransactionOrchestrator,
|
TransactionOrchestrator,
|
||||||
TransactionPayload,
|
TransactionPayload,
|
||||||
TransactionState,
|
TransactionState,
|
||||||
|
TransactionStepTimeoutError,
|
||||||
TransactionStepsDefinition,
|
TransactionStepsDefinition,
|
||||||
|
TransactionTimeoutError,
|
||||||
} from "../../transaction"
|
} from "../../transaction"
|
||||||
|
|
||||||
describe("Transaction Orchestrator", () => {
|
describe("Transaction Orchestrator", () => {
|
||||||
@@ -986,4 +990,454 @@ describe("Transaction Orchestrator", () => {
|
|||||||
|
|
||||||
expect(transaction).toBe(transactionInHandler)
|
expect(transaction).toBe(transactionInHandler)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("Timeouts - Transaction and Step", () => {
|
||||||
|
it("should fail the current steps and revert the transaction if the Transaction Timeout is reached", async () => {
|
||||||
|
const mocks = {
|
||||||
|
f1: jest.fn(() => {
|
||||||
|
return "content f1"
|
||||||
|
}),
|
||||||
|
f2: jest.fn(async () => {
|
||||||
|
await setTimeout(200)
|
||||||
|
return "delayed content f2"
|
||||||
|
}),
|
||||||
|
f3: jest.fn(() => {
|
||||||
|
return "content f3"
|
||||||
|
}),
|
||||||
|
f4: jest.fn(() => {
|
||||||
|
return "content f4"
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(
|
||||||
|
actionId: string,
|
||||||
|
functionHandlerType: TransactionHandlerType,
|
||||||
|
payload: TransactionPayload
|
||||||
|
) {
|
||||||
|
const command = {
|
||||||
|
action1: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
return mocks.f1()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f1()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action2: {
|
||||||
|
[TransactionHandlerType.INVOKE]: async () => {
|
||||||
|
return await mocks.f2()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f2()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action3: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
return mocks.f3()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f3()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action4: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
return mocks.f4()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f4()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return command[actionId][functionHandlerType]()
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow: TransactionStepsDefinition = {
|
||||||
|
next: {
|
||||||
|
action: "action1",
|
||||||
|
next: [
|
||||||
|
{
|
||||||
|
action: "action2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "action3",
|
||||||
|
next: {
|
||||||
|
action: "action4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy = new TransactionOrchestrator("transaction-name", flow, {
|
||||||
|
timeout: 0.1, // 100ms
|
||||||
|
})
|
||||||
|
|
||||||
|
const transaction = await strategy.beginTransaction(
|
||||||
|
"transaction_id_123",
|
||||||
|
handler
|
||||||
|
)
|
||||||
|
|
||||||
|
await strategy.resume(transaction)
|
||||||
|
|
||||||
|
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||||
|
expect(mocks.f1).toBeCalledTimes(2)
|
||||||
|
expect(mocks.f2).toBeCalledTimes(2)
|
||||||
|
expect(mocks.f3).toBeCalledTimes(2)
|
||||||
|
expect(mocks.f4).toBeCalledTimes(0)
|
||||||
|
expect(transaction.getContext().invoke.action1).toBe("content f1")
|
||||||
|
expect(transaction.getContext().invoke.action2).toBe("delayed content f2")
|
||||||
|
expect(transaction.getContext().invoke.action3).toBe("content f3")
|
||||||
|
expect(transaction.getContext().invoke.action4).toBe(undefined)
|
||||||
|
|
||||||
|
expect(transaction.getErrors()[0].error).toBeInstanceOf(
|
||||||
|
TransactionTimeoutError
|
||||||
|
)
|
||||||
|
expect(transaction.getErrors()[0].action).toBe("action2")
|
||||||
|
|
||||||
|
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should continue the transaction and skip children steps when the Transaction Step Timeout is reached but the step is set to 'continueOnPermanentFailure'", async () => {
|
||||||
|
const mocks = {
|
||||||
|
f1: jest.fn(() => {
|
||||||
|
return "content f1"
|
||||||
|
}),
|
||||||
|
f2: jest.fn(async () => {
|
||||||
|
await setTimeout(200)
|
||||||
|
return "delayed content f2"
|
||||||
|
}),
|
||||||
|
f3: jest.fn(() => {
|
||||||
|
return "content f3"
|
||||||
|
}),
|
||||||
|
f4: jest.fn(() => {
|
||||||
|
return "content f4"
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(
|
||||||
|
actionId: string,
|
||||||
|
functionHandlerType: TransactionHandlerType,
|
||||||
|
payload: TransactionPayload
|
||||||
|
) {
|
||||||
|
const command = {
|
||||||
|
action1: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
return mocks.f1()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f1()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action2: {
|
||||||
|
[TransactionHandlerType.INVOKE]: async () => {
|
||||||
|
return await mocks.f2()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f2()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action3: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
return mocks.f3()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f3()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action4: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
return mocks.f4()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f4()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return command[actionId][functionHandlerType]()
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow: TransactionStepsDefinition = {
|
||||||
|
next: {
|
||||||
|
action: "action1",
|
||||||
|
next: [
|
||||||
|
{
|
||||||
|
timeout: 0.1, // 100ms
|
||||||
|
action: "action2",
|
||||||
|
continueOnPermanentFailure: true,
|
||||||
|
next: {
|
||||||
|
action: "action4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "action3",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||||
|
|
||||||
|
const transaction = await strategy.beginTransaction(
|
||||||
|
"transaction_id_123",
|
||||||
|
handler
|
||||||
|
)
|
||||||
|
|
||||||
|
await strategy.resume(transaction)
|
||||||
|
|
||||||
|
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||||
|
expect(mocks.f1).toBeCalledTimes(1)
|
||||||
|
expect(mocks.f2).toBeCalledTimes(1)
|
||||||
|
expect(mocks.f3).toBeCalledTimes(1)
|
||||||
|
expect(mocks.f4).toBeCalledTimes(0)
|
||||||
|
expect(transaction.getContext().invoke.action1).toBe("content f1")
|
||||||
|
expect(transaction.getContext().invoke.action2).toBe("delayed content f2")
|
||||||
|
expect(transaction.getContext().invoke.action3).toBe("content f3")
|
||||||
|
expect(transaction.getContext().invoke.action4).toBe(undefined)
|
||||||
|
expect(
|
||||||
|
transaction.getFlow().steps["_root.action1.action2"].invoke.state
|
||||||
|
).toBe(TransactionStepState.TIMEOUT)
|
||||||
|
expect(
|
||||||
|
transaction.getFlow().steps["_root.action1.action2"].invoke.status
|
||||||
|
).toBe(TransactionStepStatus.PERMANENT_FAILURE)
|
||||||
|
expect(
|
||||||
|
transaction.getFlow().steps["_root.action1.action2"].compensate.state
|
||||||
|
).toBe(TransactionStepState.DORMANT)
|
||||||
|
expect(
|
||||||
|
transaction.getFlow().steps["_root.action1.action2.action4"].invoke
|
||||||
|
.state
|
||||||
|
).toBe(TransactionStepState.SKIPPED)
|
||||||
|
expect(
|
||||||
|
transaction.getFlow().steps["_root.action1.action2.action4"].invoke
|
||||||
|
.status
|
||||||
|
).toBe(TransactionStepStatus.IDLE)
|
||||||
|
|
||||||
|
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should fail the current steps and revert the transaction if the Step Timeout is reached", async () => {
|
||||||
|
const mocks = {
|
||||||
|
f1: jest.fn(() => {
|
||||||
|
return "content f1"
|
||||||
|
}),
|
||||||
|
f2: jest.fn(async () => {
|
||||||
|
await setTimeout(200)
|
||||||
|
return "delayed content f2"
|
||||||
|
}),
|
||||||
|
f3: jest.fn(() => {
|
||||||
|
return "content f3"
|
||||||
|
}),
|
||||||
|
f4: jest.fn(() => {
|
||||||
|
return "content f4"
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(
|
||||||
|
actionId: string,
|
||||||
|
functionHandlerType: TransactionHandlerType,
|
||||||
|
payload: TransactionPayload
|
||||||
|
) {
|
||||||
|
const command = {
|
||||||
|
action1: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
return mocks.f1()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f1()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action2: {
|
||||||
|
[TransactionHandlerType.INVOKE]: async () => {
|
||||||
|
return await mocks.f2()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f2()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action3: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
return mocks.f3()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f3()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action4: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
return mocks.f4()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f4()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return command[actionId][functionHandlerType]()
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow: TransactionStepsDefinition = {
|
||||||
|
next: {
|
||||||
|
action: "action1",
|
||||||
|
next: [
|
||||||
|
{
|
||||||
|
action: "action2",
|
||||||
|
timeout: 0.1, // 100ms
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "action3",
|
||||||
|
next: {
|
||||||
|
action: "action4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||||
|
|
||||||
|
const transaction = await strategy.beginTransaction(
|
||||||
|
"transaction_id_123",
|
||||||
|
handler
|
||||||
|
)
|
||||||
|
|
||||||
|
await strategy.resume(transaction)
|
||||||
|
|
||||||
|
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||||
|
expect(mocks.f1).toBeCalledTimes(2)
|
||||||
|
expect(mocks.f2).toBeCalledTimes(2)
|
||||||
|
expect(mocks.f3).toBeCalledTimes(2)
|
||||||
|
expect(mocks.f4).toBeCalledTimes(0)
|
||||||
|
expect(transaction.getContext().invoke.action1).toBe("content f1")
|
||||||
|
expect(transaction.getContext().invoke.action2).toBe("delayed content f2")
|
||||||
|
expect(transaction.getContext().invoke.action3).toBe("content f3")
|
||||||
|
expect(transaction.getContext().invoke.action4).toBe(undefined)
|
||||||
|
|
||||||
|
expect(transaction.getErrors()[0].error).toBeInstanceOf(
|
||||||
|
TransactionStepTimeoutError
|
||||||
|
)
|
||||||
|
expect(transaction.getErrors()[0].action).toBe("action2")
|
||||||
|
|
||||||
|
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should fail the current steps and revert the transaction if the Transaction Timeout is reached event if the step is set as 'continueOnPermanentFailure'", async () => {
|
||||||
|
const mocks = {
|
||||||
|
f1: jest.fn(() => {
|
||||||
|
return "content f1"
|
||||||
|
}),
|
||||||
|
f2: jest.fn(async () => {
|
||||||
|
await setTimeout(200)
|
||||||
|
return "delayed content f2"
|
||||||
|
}),
|
||||||
|
f3: jest.fn(async () => {
|
||||||
|
await setTimeout(200)
|
||||||
|
return "content f3"
|
||||||
|
}),
|
||||||
|
f4: jest.fn(() => {
|
||||||
|
return "content f4"
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(
|
||||||
|
actionId: string,
|
||||||
|
functionHandlerType: TransactionHandlerType,
|
||||||
|
payload: TransactionPayload
|
||||||
|
) {
|
||||||
|
const command = {
|
||||||
|
action1: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
return mocks.f1()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f1()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action2: {
|
||||||
|
[TransactionHandlerType.INVOKE]: async () => {
|
||||||
|
return await mocks.f2()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f2()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action3: {
|
||||||
|
[TransactionHandlerType.INVOKE]: async () => {
|
||||||
|
return await mocks.f3()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f3()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action4: {
|
||||||
|
[TransactionHandlerType.INVOKE]: () => {
|
||||||
|
return mocks.f4()
|
||||||
|
},
|
||||||
|
[TransactionHandlerType.COMPENSATE]: () => {
|
||||||
|
return mocks.f4()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return command[actionId][functionHandlerType]()
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow: TransactionStepsDefinition = {
|
||||||
|
next: {
|
||||||
|
action: "action1",
|
||||||
|
next: [
|
||||||
|
{
|
||||||
|
action: "action2",
|
||||||
|
continueOnPermanentFailure: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "action3",
|
||||||
|
continueOnPermanentFailure: true,
|
||||||
|
next: {
|
||||||
|
action: "action4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy = new TransactionOrchestrator("transaction-name", flow, {
|
||||||
|
timeout: 0.1, // 100ms
|
||||||
|
})
|
||||||
|
|
||||||
|
const transaction = await strategy.beginTransaction(
|
||||||
|
"transaction_id_123",
|
||||||
|
handler
|
||||||
|
)
|
||||||
|
|
||||||
|
await strategy.resume(transaction)
|
||||||
|
|
||||||
|
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||||
|
expect(mocks.f1).toBeCalledTimes(2)
|
||||||
|
expect(mocks.f2).toBeCalledTimes(2)
|
||||||
|
expect(mocks.f3).toBeCalledTimes(2)
|
||||||
|
expect(mocks.f4).toBeCalledTimes(0)
|
||||||
|
expect(transaction.getContext().invoke.action1).toBe("content f1")
|
||||||
|
expect(transaction.getContext().invoke.action2).toBe("delayed content f2")
|
||||||
|
expect(transaction.getContext().invoke.action3).toBe("content f3")
|
||||||
|
expect(transaction.getContext().invoke.action4).toBe(undefined)
|
||||||
|
|
||||||
|
expect(transaction.getErrors()).toHaveLength(2)
|
||||||
|
expect(
|
||||||
|
TransactionTimeoutError.isTransactionTimeoutError(
|
||||||
|
transaction.getErrors()[0].error
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
expect(transaction.getErrors()[0].action).toBe("action2")
|
||||||
|
|
||||||
|
expect(
|
||||||
|
TransactionTimeoutError.isTransactionTimeoutError(
|
||||||
|
transaction.getErrors()[1].error
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
expect(transaction.getErrors()[1].action).toBe("action3")
|
||||||
|
|
||||||
|
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -3,14 +3,11 @@ import {
|
|||||||
TransactionCheckpoint,
|
TransactionCheckpoint,
|
||||||
} from "../distributed-transaction"
|
} from "../distributed-transaction"
|
||||||
import { TransactionStep } from "../transaction-step"
|
import { TransactionStep } from "../transaction-step"
|
||||||
import { TransactionModelOptions } from "../types"
|
|
||||||
|
|
||||||
export interface IDistributedTransactionStorage {
|
export interface IDistributedTransactionStorage {
|
||||||
get(key: string): Promise<TransactionCheckpoint | undefined>
|
get(key: string): Promise<TransactionCheckpoint | undefined>
|
||||||
list(): Promise<TransactionCheckpoint[]>
|
list(): Promise<TransactionCheckpoint[]>
|
||||||
save(key: string, data: TransactionCheckpoint, ttl?: number): Promise<void>
|
save(key: string, data: TransactionCheckpoint, ttl?: number): Promise<void>
|
||||||
delete(key: string): Promise<void>
|
|
||||||
archive(key: string, options?: TransactionModelOptions): Promise<void>
|
|
||||||
scheduleRetry(
|
scheduleRetry(
|
||||||
transaction: DistributedTransaction,
|
transaction: DistributedTransaction,
|
||||||
step: TransactionStep,
|
step: TransactionStep,
|
||||||
@@ -62,14 +59,6 @@ export abstract class DistributedTransactionStorage
|
|||||||
throw new Error("Method 'save' not implemented.")
|
throw new Error("Method 'save' not implemented.")
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
|
||||||
throw new Error("Method 'delete' not implemented.")
|
|
||||||
}
|
|
||||||
|
|
||||||
async archive(key: string, options?: TransactionModelOptions): Promise<void> {
|
|
||||||
throw new Error("Method 'archive' not implemented.")
|
|
||||||
}
|
|
||||||
|
|
||||||
async scheduleRetry(
|
async scheduleRetry(
|
||||||
transaction: DistributedTransaction,
|
transaction: DistributedTransaction,
|
||||||
step: TransactionStep,
|
step: TransactionStep,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { TransactionState } from "@medusajs/utils"
|
||||||
import { TransactionCheckpoint } from "../distributed-transaction"
|
import { TransactionCheckpoint } from "../distributed-transaction"
|
||||||
import { TransactionModelOptions } from "../types"
|
|
||||||
import { DistributedTransactionStorage } from "./abstract-storage"
|
import { DistributedTransactionStorage } from "./abstract-storage"
|
||||||
|
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
@@ -24,14 +24,16 @@ export class BaseInMemoryDistributedTransactionStorage extends DistributedTransa
|
|||||||
data: TransactionCheckpoint,
|
data: TransactionCheckpoint,
|
||||||
ttl?: number
|
ttl?: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const hasFinished = [
|
||||||
|
TransactionState.DONE,
|
||||||
|
TransactionState.REVERTED,
|
||||||
|
TransactionState.FAILED,
|
||||||
|
].includes(data.flow.state)
|
||||||
|
|
||||||
|
if (hasFinished) {
|
||||||
|
this.storage.delete(key)
|
||||||
|
} else {
|
||||||
this.storage.set(key, data)
|
this.storage.set(key, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
|
||||||
this.storage.delete(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
async archive(key: string, options?: TransactionModelOptions): Promise<void> {
|
|
||||||
this.storage.delete(key)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ export class DistributedTransaction extends EventEmitter {
|
|||||||
this.keyValueStore = storage
|
this.keyValueStore = storage
|
||||||
}
|
}
|
||||||
|
|
||||||
private static keyPrefix = "dtrans"
|
public static keyPrefix = "dtrans"
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private flow: TransactionFlow,
|
private flow: TransactionFlow,
|
||||||
@@ -177,18 +177,18 @@ export class DistributedTransaction extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public hasTimeout(): boolean {
|
public hasTimeout(): boolean {
|
||||||
return !!this.getFlow().definition.timeout
|
return !!this.getTimeout()
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTimeoutInterval(): number | undefined {
|
public getTimeout(): number | undefined {
|
||||||
return this.getFlow().definition.timeout
|
return this.getFlow().options?.timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
public async saveCheckpoint(
|
public async saveCheckpoint(
|
||||||
ttl = 0
|
ttl = 0
|
||||||
): Promise<TransactionCheckpoint | undefined> {
|
): Promise<TransactionCheckpoint | undefined> {
|
||||||
const options = this.getFlow().options
|
const options = this.getFlow().options
|
||||||
if (!options?.storeExecution) {
|
if (!options?.store) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,31 +226,6 @@ export class DistributedTransaction extends EventEmitter {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteCheckpoint(): Promise<void> {
|
|
||||||
const options = this.getFlow().options
|
|
||||||
if (!options?.storeExecution) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = TransactionOrchestrator.getKeyName(
|
|
||||||
DistributedTransaction.keyPrefix,
|
|
||||||
this.modelId,
|
|
||||||
this.transactionId
|
|
||||||
)
|
|
||||||
await DistributedTransaction.keyValueStore.delete(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async archiveCheckpoint(): Promise<void> {
|
|
||||||
const options = this.getFlow().options
|
|
||||||
|
|
||||||
const key = TransactionOrchestrator.getKeyName(
|
|
||||||
DistributedTransaction.keyPrefix,
|
|
||||||
this.modelId,
|
|
||||||
this.transactionId
|
|
||||||
)
|
|
||||||
await DistributedTransaction.keyValueStore.archive(key, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async scheduleRetry(
|
public async scheduleRetry(
|
||||||
step: TransactionStep,
|
step: TransactionStep,
|
||||||
interval: number
|
interval: number
|
||||||
@@ -269,6 +244,11 @@ export class DistributedTransaction extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async scheduleTransactionTimeout(interval: number): Promise<void> {
|
public async scheduleTransactionTimeout(interval: number): Promise<void> {
|
||||||
|
// schedule transaction timeout only if there are async steps
|
||||||
|
if (!this.getFlow().hasAsyncSteps) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await this.saveCheckpoint()
|
await this.saveCheckpoint()
|
||||||
await DistributedTransaction.keyValueStore.scheduleTransactionTimeout(
|
await DistributedTransaction.keyValueStore.scheduleTransactionTimeout(
|
||||||
this,
|
this,
|
||||||
@@ -278,6 +258,10 @@ export class DistributedTransaction extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async clearTransactionTimeout(): Promise<void> {
|
public async clearTransactionTimeout(): Promise<void> {
|
||||||
|
if (!this.getFlow().hasAsyncSteps) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await DistributedTransaction.keyValueStore.clearTransactionTimeout(this)
|
await DistributedTransaction.keyValueStore.clearTransactionTimeout(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +269,11 @@ export class DistributedTransaction extends EventEmitter {
|
|||||||
step: TransactionStep,
|
step: TransactionStep,
|
||||||
interval: number
|
interval: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// schedule step timeout only if the step is async
|
||||||
|
if (!step.definition.async) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await this.saveCheckpoint()
|
await this.saveCheckpoint()
|
||||||
await DistributedTransaction.keyValueStore.scheduleStepTimeout(
|
await DistributedTransaction.keyValueStore.scheduleStepTimeout(
|
||||||
this,
|
this,
|
||||||
@@ -295,6 +284,10 @@ export class DistributedTransaction extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async clearStepTimeout(step: TransactionStep): Promise<void> {
|
public async clearStepTimeout(step: TransactionStep): Promise<void> {
|
||||||
|
if (!step.definition.async || step.isCompensating()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await DistributedTransaction.keyValueStore.clearStepTimeout(this, step)
|
await DistributedTransaction.keyValueStore.clearStepTimeout(this, step)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export class PermanentStepFailureError extends Error {
|
|||||||
): error is PermanentStepFailureError {
|
): error is PermanentStepFailureError {
|
||||||
return (
|
return (
|
||||||
error instanceof PermanentStepFailureError ||
|
error instanceof PermanentStepFailureError ||
|
||||||
error.name === "PermanentStepFailure"
|
error?.name === "PermanentStepFailure"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,16 +14,19 @@ export class PermanentStepFailureError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StepTimeoutError extends Error {
|
export class TransactionStepTimeoutError extends Error {
|
||||||
static isStepTimeoutError(error: Error): error is StepTimeoutError {
|
static isTransactionStepTimeoutError(
|
||||||
|
error: Error
|
||||||
|
): error is TransactionStepTimeoutError {
|
||||||
return (
|
return (
|
||||||
error instanceof StepTimeoutError || error.name === "StepTimeoutError"
|
error instanceof TransactionStepTimeoutError ||
|
||||||
|
error?.name === "TransactionStepTimeoutError"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(message?: string) {
|
constructor(message?: string) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = "StepTimeoutError"
|
this.name = "TransactionStepTimeoutError"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +36,7 @@ export class TransactionTimeoutError extends Error {
|
|||||||
): error is TransactionTimeoutError {
|
): error is TransactionTimeoutError {
|
||||||
return (
|
return (
|
||||||
error instanceof TransactionTimeoutError ||
|
error instanceof TransactionTimeoutError ||
|
||||||
error.name === "TransactionTimeoutError"
|
error?.name === "TransactionTimeoutError"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ export class OrchestratorBuilder {
|
|||||||
action: string,
|
action: string,
|
||||||
step: InternalStep = this.steps
|
step: InternalStep = this.steps
|
||||||
): InternalStep | undefined {
|
): InternalStep | undefined {
|
||||||
if (step.action === action) {
|
if (step.uuid === action || step.action === action) {
|
||||||
return step
|
return step
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +357,7 @@ export class OrchestratorBuilder {
|
|||||||
if (!nextStep) {
|
if (!nextStep) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (nextStep.action === action) {
|
if (nextStep.uuid === action || nextStep.action === action) {
|
||||||
return step
|
return step
|
||||||
}
|
}
|
||||||
const foundStep = this.findParentStepByAction(
|
const foundStep = this.findParentStepByAction(
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import {
|
|||||||
TransactionStepStatus,
|
TransactionStepStatus,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
import { MedusaError, promiseAll } from "@medusajs/utils"
|
import { MedusaError, promiseAll, TransactionStepState } from "@medusajs/utils"
|
||||||
import { EventEmitter } from "events"
|
import { EventEmitter } from "events"
|
||||||
import {
|
import {
|
||||||
PermanentStepFailureError,
|
PermanentStepFailureError,
|
||||||
StepTimeoutError,
|
TransactionStepTimeoutError,
|
||||||
TransactionTimeoutError,
|
TransactionTimeoutError,
|
||||||
} from "./errors"
|
} from "./errors"
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ export type TransactionFlow = {
|
|||||||
hasFailedSteps: boolean
|
hasFailedSteps: boolean
|
||||||
hasWaitingSteps: boolean
|
hasWaitingSteps: boolean
|
||||||
hasSkippedSteps: boolean
|
hasSkippedSteps: boolean
|
||||||
|
hasRevertedSteps: boolean
|
||||||
timedOutAt: number | null
|
timedOutAt: number | null
|
||||||
startedAt?: number
|
startedAt?: number
|
||||||
state: TransactionState
|
state: TransactionState
|
||||||
@@ -62,10 +63,6 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
return params.join(this.SEPARATOR)
|
return params.join(this.SEPARATOR)
|
||||||
}
|
}
|
||||||
|
|
||||||
public getOptions(): TransactionModelOptions {
|
|
||||||
return this.options ?? {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPreviousStep(flow: TransactionFlow, step: TransactionStep) {
|
private getPreviousStep(flow: TransactionFlow, step: TransactionStep) {
|
||||||
const id = step.id.split(".")
|
const id = step.id.split(".")
|
||||||
id.pop()
|
id.pop()
|
||||||
@@ -73,6 +70,10 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
return flow.steps[parentId]
|
return flow.steps[parentId]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getOptions(): TransactionModelOptions {
|
||||||
|
return this.options ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
private getInvokeSteps(flow: TransactionFlow): string[] {
|
private getInvokeSteps(flow: TransactionFlow): string[] {
|
||||||
if (this.invokeSteps.length) {
|
if (this.invokeSteps.length) {
|
||||||
return this.invokeSteps
|
return this.invokeSteps
|
||||||
@@ -102,9 +103,10 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
|
|
||||||
private canMoveForward(flow: TransactionFlow, previousStep: TransactionStep) {
|
private canMoveForward(flow: TransactionFlow, previousStep: TransactionStep) {
|
||||||
const states = [
|
const states = [
|
||||||
TransactionState.DONE,
|
TransactionStepState.DONE,
|
||||||
TransactionState.FAILED,
|
TransactionStepState.FAILED,
|
||||||
TransactionState.SKIPPED,
|
TransactionStepState.TIMEOUT,
|
||||||
|
TransactionStepState.SKIPPED,
|
||||||
]
|
]
|
||||||
|
|
||||||
const siblings = this.getPreviousStep(flow, previousStep).next.map(
|
const siblings = this.getPreviousStep(flow, previousStep).next.map(
|
||||||
@@ -119,10 +121,10 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
|
|
||||||
private canMoveBackward(flow: TransactionFlow, step: TransactionStep) {
|
private canMoveBackward(flow: TransactionFlow, step: TransactionStep) {
|
||||||
const states = [
|
const states = [
|
||||||
TransactionState.DONE,
|
TransactionStepState.DONE,
|
||||||
TransactionState.REVERTED,
|
TransactionStepState.REVERTED,
|
||||||
TransactionState.FAILED,
|
TransactionStepState.FAILED,
|
||||||
TransactionState.DORMANT,
|
TransactionStepState.DORMANT,
|
||||||
]
|
]
|
||||||
const siblings = step.next.map((sib) => flow.steps[sib])
|
const siblings = step.next.map((sib) => flow.steps[sib])
|
||||||
return (
|
return (
|
||||||
@@ -144,29 +146,89 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkStepTimeout(transaction, step) {
|
private hasExpired(
|
||||||
let hasTimedOut = false
|
{
|
||||||
if (
|
|
||||||
step.hasTimeout() &&
|
|
||||||
!step.timedOutAt &&
|
|
||||||
step.canCancel() &&
|
|
||||||
step.startedAt! + step.getTimeoutInterval()! * 1e3 < Date.now()
|
|
||||||
) {
|
|
||||||
step.timedOutAt = Date.now()
|
|
||||||
await transaction.saveCheckpoint()
|
|
||||||
this.emit(DistributedTransactionEvent.TIMEOUT, { transaction })
|
|
||||||
await TransactionOrchestrator.setStepFailure(
|
|
||||||
transaction,
|
transaction,
|
||||||
step,
|
step,
|
||||||
new StepTimeoutError(),
|
}: {
|
||||||
0
|
transaction?: DistributedTransaction
|
||||||
|
step?: TransactionStep
|
||||||
|
},
|
||||||
|
dateNow: number
|
||||||
|
): boolean {
|
||||||
|
const hasStepTimedOut =
|
||||||
|
step &&
|
||||||
|
step.hasTimeout() &&
|
||||||
|
!step.isCompensating() &&
|
||||||
|
dateNow > step.startedAt! + step.getTimeout()! * 1e3
|
||||||
|
|
||||||
|
const hasTransactionTimedOut =
|
||||||
|
transaction &&
|
||||||
|
transaction.hasTimeout() &&
|
||||||
|
transaction.getFlow().state !== TransactionState.COMPENSATING &&
|
||||||
|
dateNow >
|
||||||
|
transaction.getFlow().startedAt! + transaction.getTimeout()! * 1e3
|
||||||
|
|
||||||
|
return !!hasStepTimedOut || !!hasTransactionTimedOut
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkTransactionTimeout(
|
||||||
|
transaction: DistributedTransaction,
|
||||||
|
currentSteps: TransactionStep[]
|
||||||
|
) {
|
||||||
|
const flow = transaction.getFlow()
|
||||||
|
let hasTimedOut = false
|
||||||
|
if (!flow.timedOutAt && this.hasExpired({ transaction }, Date.now())) {
|
||||||
|
flow.timedOutAt = Date.now()
|
||||||
|
|
||||||
|
void transaction.clearTransactionTimeout()
|
||||||
|
|
||||||
|
for (const step of currentSteps) {
|
||||||
|
await TransactionOrchestrator.setStepTimeout(
|
||||||
|
transaction,
|
||||||
|
step,
|
||||||
|
new TransactionTimeoutError()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.saveCheckpoint()
|
||||||
|
|
||||||
|
this.emit(DistributedTransactionEvent.TIMEOUT, { transaction })
|
||||||
|
|
||||||
|
hasTimedOut = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasTimedOut
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkStepTimeout(
|
||||||
|
transaction: DistributedTransaction,
|
||||||
|
step: TransactionStep
|
||||||
|
) {
|
||||||
|
let hasTimedOut = false
|
||||||
|
if (
|
||||||
|
!step.timedOutAt &&
|
||||||
|
step.canCancel() &&
|
||||||
|
this.hasExpired({ step }, Date.now())
|
||||||
|
) {
|
||||||
|
step.timedOutAt = Date.now()
|
||||||
|
|
||||||
|
await TransactionOrchestrator.setStepTimeout(
|
||||||
|
transaction,
|
||||||
|
step,
|
||||||
|
new TransactionStepTimeoutError()
|
||||||
)
|
)
|
||||||
hasTimedOut = true
|
hasTimedOut = true
|
||||||
|
|
||||||
|
await transaction.saveCheckpoint()
|
||||||
|
|
||||||
|
this.emit(DistributedTransactionEvent.TIMEOUT, { transaction })
|
||||||
}
|
}
|
||||||
return hasTimedOut
|
return hasTimedOut
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkAllSteps(transaction: DistributedTransaction): Promise<{
|
private async checkAllSteps(transaction: DistributedTransaction): Promise<{
|
||||||
|
current: TransactionStep[]
|
||||||
next: TransactionStep[]
|
next: TransactionStep[]
|
||||||
total: number
|
total: number
|
||||||
remaining: number
|
remaining: number
|
||||||
@@ -182,6 +244,8 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
const flow = transaction.getFlow()
|
const flow = transaction.getFlow()
|
||||||
|
|
||||||
const nextSteps: TransactionStep[] = []
|
const nextSteps: TransactionStep[] = []
|
||||||
|
const currentSteps: TransactionStep[] = []
|
||||||
|
|
||||||
const allSteps =
|
const allSteps =
|
||||||
flow.state === TransactionState.COMPENSATING
|
flow.state === TransactionState.COMPENSATING
|
||||||
? this.getCompensationSteps(flow)
|
? this.getCompensationSteps(flow)
|
||||||
@@ -204,6 +268,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (curState.status === TransactionStepStatus.WAITING) {
|
if (curState.status === TransactionStepStatus.WAITING) {
|
||||||
|
currentSteps.push(stepDef)
|
||||||
hasWaiting = true
|
hasWaiting = true
|
||||||
|
|
||||||
if (stepDef.hasAwaitingRetry()) {
|
if (stepDef.hasAwaitingRetry()) {
|
||||||
@@ -223,6 +288,8 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
|
|
||||||
continue
|
continue
|
||||||
} else if (curState.status === TransactionStepStatus.TEMPORARY_FAILURE) {
|
} else if (curState.status === TransactionStepStatus.TEMPORARY_FAILURE) {
|
||||||
|
currentSteps.push(stepDef)
|
||||||
|
|
||||||
if (!stepDef.canRetry()) {
|
if (!stepDef.canRetry()) {
|
||||||
if (stepDef.hasRetryInterval() && !stepDef.retryRescheduledAt) {
|
if (stepDef.hasRetryInterval() && !stepDef.retryRescheduledAt) {
|
||||||
stepDef.hasScheduledRetry = true
|
stepDef.hasScheduledRetry = true
|
||||||
@@ -243,11 +310,11 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
} else {
|
} else {
|
||||||
completedSteps++
|
completedSteps++
|
||||||
|
|
||||||
if (curState.state === TransactionState.SKIPPED) {
|
if (curState.state === TransactionStepState.SKIPPED) {
|
||||||
hasSkipped = true
|
hasSkipped = true
|
||||||
} else if (curState.state === TransactionState.REVERTED) {
|
} else if (curState.state === TransactionStepState.REVERTED) {
|
||||||
hasReverted = true
|
hasReverted = true
|
||||||
} else if (curState.state === TransactionState.FAILED) {
|
} else if (curState.state === TransactionStepState.FAILED) {
|
||||||
if (stepDef.definition.continueOnPermanentFailure) {
|
if (stepDef.definition.continueOnPermanentFailure) {
|
||||||
hasIgnoredFailure = true
|
hasIgnoredFailure = true
|
||||||
} else {
|
} else {
|
||||||
@@ -258,6 +325,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
flow.hasWaitingSteps = hasWaiting
|
flow.hasWaitingSteps = hasWaiting
|
||||||
|
flow.hasRevertedSteps = hasReverted
|
||||||
|
|
||||||
const totalSteps = allSteps.length - 1
|
const totalSteps = allSteps.length - 1
|
||||||
if (
|
if (
|
||||||
@@ -288,6 +356,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
current: currentSteps,
|
||||||
next: nextSteps,
|
next: nextSteps,
|
||||||
total: totalSteps,
|
total: totalSteps,
|
||||||
remaining: totalSteps - completedSteps,
|
remaining: totalSteps - completedSteps,
|
||||||
@@ -304,11 +373,13 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
const stepDef = flow.steps[step]
|
const stepDef = flow.steps[step]
|
||||||
const curState = stepDef.getStates()
|
const curState = stepDef.getStates()
|
||||||
if (
|
if (
|
||||||
curState.state === TransactionState.DONE ||
|
[TransactionStepState.DONE, TransactionStepState.TIMEOUT].includes(
|
||||||
|
curState.state
|
||||||
|
) ||
|
||||||
curState.status === TransactionStepStatus.PERMANENT_FAILURE
|
curState.status === TransactionStepStatus.PERMANENT_FAILURE
|
||||||
) {
|
) {
|
||||||
stepDef.beginCompensation()
|
stepDef.beginCompensation()
|
||||||
stepDef.changeState(TransactionState.NOT_STARTED)
|
stepDef.changeState(TransactionStepState.NOT_STARTED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,6 +389,9 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
step: TransactionStep,
|
step: TransactionStep,
|
||||||
response: unknown
|
response: unknown
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const hasStepTimedOut =
|
||||||
|
step.getStates().state === TransactionStepState.TIMEOUT
|
||||||
|
|
||||||
if (step.saveResponse) {
|
if (step.saveResponse) {
|
||||||
transaction.addResponse(
|
transaction.addResponse(
|
||||||
step.definition.action!,
|
step.definition.action!,
|
||||||
@@ -328,16 +402,19 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
step.changeStatus(TransactionStepStatus.OK)
|
const flow = transaction.getFlow()
|
||||||
|
|
||||||
if (step.isCompensating()) {
|
if (!hasStepTimedOut) {
|
||||||
step.changeState(TransactionState.REVERTED)
|
step.changeStatus(TransactionStepStatus.OK)
|
||||||
} else {
|
|
||||||
step.changeState(TransactionState.DONE)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const flow = transaction.getFlow()
|
if (step.isCompensating()) {
|
||||||
if (step.definition.async || flow.options?.strictCheckpoints) {
|
step.changeState(TransactionStepState.REVERTED)
|
||||||
|
} else if (!hasStepTimedOut) {
|
||||||
|
step.changeState(TransactionStepState.DONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.definition.async || flow.options?.storeExecution) {
|
||||||
await transaction.saveCheckpoint()
|
await transaction.saveCheckpoint()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,22 +434,70 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
transaction.emit(eventName, { step, transaction })
|
transaction.emit(eventName, { step, transaction })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async setStepTimeout(
|
||||||
|
transaction: DistributedTransaction,
|
||||||
|
step: TransactionStep,
|
||||||
|
error: TransactionStepTimeoutError | TransactionTimeoutError
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
TransactionStepState.TIMEOUT,
|
||||||
|
TransactionStepState.DONE,
|
||||||
|
TransactionStepState.REVERTED,
|
||||||
|
].includes(step.getStates().state)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
step.changeState(TransactionStepState.TIMEOUT)
|
||||||
|
|
||||||
|
transaction.addError(
|
||||||
|
step.definition.action!,
|
||||||
|
TransactionHandlerType.INVOKE,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
|
||||||
|
await TransactionOrchestrator.setStepFailure(
|
||||||
|
transaction,
|
||||||
|
step,
|
||||||
|
undefined,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
|
||||||
|
await transaction.clearStepTimeout(step)
|
||||||
|
}
|
||||||
|
|
||||||
private static async setStepFailure(
|
private static async setStepFailure(
|
||||||
transaction: DistributedTransaction,
|
transaction: DistributedTransaction,
|
||||||
step: TransactionStep,
|
step: TransactionStep,
|
||||||
error: Error | any,
|
error: Error | any,
|
||||||
maxRetries: number = TransactionOrchestrator.DEFAULT_RETRIES
|
maxRetries: number = TransactionOrchestrator.DEFAULT_RETRIES,
|
||||||
|
isTimeout = false,
|
||||||
|
timeoutError?: TransactionStepTimeoutError | TransactionTimeoutError
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
step.failures++
|
step.failures++
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isTimeout &&
|
||||||
|
step.getStates().status !== TransactionStepStatus.PERMANENT_FAILURE
|
||||||
|
) {
|
||||||
step.changeStatus(TransactionStepStatus.TEMPORARY_FAILURE)
|
step.changeStatus(TransactionStepStatus.TEMPORARY_FAILURE)
|
||||||
|
}
|
||||||
|
|
||||||
const flow = transaction.getFlow()
|
const flow = transaction.getFlow()
|
||||||
const cleaningUp: Promise<unknown>[] = []
|
const cleaningUp: Promise<unknown>[] = []
|
||||||
if (step.failures > maxRetries) {
|
|
||||||
step.changeState(TransactionState.FAILED)
|
const hasTimedOut = step.getStates().state === TransactionStepState.TIMEOUT
|
||||||
|
if (step.failures > maxRetries || hasTimedOut) {
|
||||||
|
if (!hasTimedOut) {
|
||||||
|
step.changeState(TransactionStepState.FAILED)
|
||||||
|
}
|
||||||
|
|
||||||
step.changeStatus(TransactionStepStatus.PERMANENT_FAILURE)
|
step.changeStatus(TransactionStepStatus.PERMANENT_FAILURE)
|
||||||
|
|
||||||
|
if (!isTimeout) {
|
||||||
transaction.addError(
|
transaction.addError(
|
||||||
step.definition.action!,
|
step.definition.action!,
|
||||||
step.isCompensating()
|
step.isCompensating()
|
||||||
@@ -380,12 +505,16 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
: TransactionHandlerType.INVOKE,
|
: TransactionHandlerType.INVOKE,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!step.isCompensating()) {
|
if (!step.isCompensating()) {
|
||||||
if (step.definition.continueOnPermanentFailure) {
|
if (
|
||||||
|
step.definition.continueOnPermanentFailure &&
|
||||||
|
!TransactionTimeoutError.isTransactionTimeoutError(timeoutError!)
|
||||||
|
) {
|
||||||
for (const childStep of step.next) {
|
for (const childStep of step.next) {
|
||||||
const child = flow.steps[childStep]
|
const child = flow.steps[childStep]
|
||||||
child.changeState(TransactionState.SKIPPED)
|
child.changeState(TransactionStepState.SKIPPED)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
flow.state = TransactionState.WAITING_TO_COMPENSATE
|
flow.state = TransactionState.WAITING_TO_COMPENSATE
|
||||||
@@ -397,7 +526,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step.definition.async || flow.options?.strictCheckpoints) {
|
if (step.definition.async || flow.options?.storeExecution) {
|
||||||
await transaction.saveCheckpoint()
|
await transaction.saveCheckpoint()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,33 +542,6 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
transaction.emit(eventName, { step, transaction })
|
transaction.emit(eventName, { step, transaction })
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkTransactionTimeout(transaction, currentSteps) {
|
|
||||||
let hasTimedOut = false
|
|
||||||
const flow = transaction.getFlow()
|
|
||||||
if (
|
|
||||||
transaction.hasTimeout() &&
|
|
||||||
!flow.timedOutAt &&
|
|
||||||
flow.startedAt! + transaction.getTimeoutInterval()! * 1e3 < Date.now()
|
|
||||||
) {
|
|
||||||
flow.timedOutAt = Date.now()
|
|
||||||
this.emit(DistributedTransactionEvent.TIMEOUT, { transaction })
|
|
||||||
|
|
||||||
for (const step of currentSteps) {
|
|
||||||
await TransactionOrchestrator.setStepFailure(
|
|
||||||
transaction,
|
|
||||||
step,
|
|
||||||
new TransactionTimeoutError(),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await transaction.saveCheckpoint()
|
|
||||||
|
|
||||||
hasTimedOut = true
|
|
||||||
}
|
|
||||||
return hasTimedOut
|
|
||||||
}
|
|
||||||
|
|
||||||
private async executeNext(
|
private async executeNext(
|
||||||
transaction: DistributedTransaction
|
transaction: DistributedTransaction
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -456,22 +558,19 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
|
|
||||||
const hasTimedOut = await this.checkTransactionTimeout(
|
const hasTimedOut = await this.checkTransactionTimeout(
|
||||||
transaction,
|
transaction,
|
||||||
nextSteps.next
|
nextSteps.current
|
||||||
)
|
)
|
||||||
|
|
||||||
if (hasTimedOut) {
|
if (hasTimedOut) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextSteps.remaining === 0) {
|
if (nextSteps.remaining === 0) {
|
||||||
if (transaction.hasTimeout()) {
|
if (transaction.hasTimeout()) {
|
||||||
await transaction.clearTransactionTimeout()
|
void transaction.clearTransactionTimeout()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flow.options?.retentionTime == undefined) {
|
|
||||||
await transaction.deleteCheckpoint()
|
|
||||||
} else {
|
|
||||||
await transaction.saveCheckpoint()
|
await transaction.saveCheckpoint()
|
||||||
}
|
|
||||||
|
|
||||||
this.emit(DistributedTransactionEvent.FINISH, { transaction })
|
this.emit(DistributedTransactionEvent.FINISH, { transaction })
|
||||||
}
|
}
|
||||||
@@ -486,20 +585,20 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
step.lastAttempt = Date.now()
|
step.lastAttempt = Date.now()
|
||||||
step.attempts++
|
step.attempts++
|
||||||
|
|
||||||
if (curState.state === TransactionState.NOT_STARTED) {
|
if (curState.state === TransactionStepState.NOT_STARTED) {
|
||||||
if (!step.startedAt) {
|
if (!step.startedAt) {
|
||||||
step.startedAt = Date.now()
|
step.startedAt = Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step.isCompensating()) {
|
if (step.isCompensating()) {
|
||||||
step.changeState(TransactionState.COMPENSATING)
|
step.changeState(TransactionStepState.COMPENSATING)
|
||||||
|
|
||||||
if (step.definition.noCompensation) {
|
if (step.definition.noCompensation) {
|
||||||
step.changeState(TransactionState.REVERTED)
|
step.changeState(TransactionStepState.REVERTED)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else if (flow.state === TransactionState.INVOKING) {
|
} else if (flow.state === TransactionState.INVOKING) {
|
||||||
step.changeState(TransactionState.INVOKING)
|
step.changeState(TransactionStepState.INVOKING)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,6 +653,14 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
transaction
|
transaction
|
||||||
.handler(step.definition.action + "", type, payload, transaction)
|
.handler(step.definition.action + "", type, payload, transaction)
|
||||||
.then(async (response: any) => {
|
.then(async (response: any) => {
|
||||||
|
if (this.hasExpired({ transaction, step }, Date.now())) {
|
||||||
|
await this.checkStepTimeout(transaction, step)
|
||||||
|
await this.checkTransactionTimeout(
|
||||||
|
transaction,
|
||||||
|
nextSteps.next.includes(step) ? nextSteps.next : [step]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
await TransactionOrchestrator.setStepSuccess(
|
await TransactionOrchestrator.setStepSuccess(
|
||||||
transaction,
|
transaction,
|
||||||
step,
|
step,
|
||||||
@@ -561,6 +668,14 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.catch(async (error) => {
|
.catch(async (error) => {
|
||||||
|
if (this.hasExpired({ transaction, step }, Date.now())) {
|
||||||
|
await this.checkStepTimeout(transaction, step)
|
||||||
|
await this.checkTransactionTimeout(
|
||||||
|
transaction,
|
||||||
|
nextSteps.next.includes(step) ? nextSteps.next : [step]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
PermanentStepFailureError.isPermanentStepFailureError(error)
|
PermanentStepFailureError.isPermanentStepFailureError(error)
|
||||||
) {
|
) {
|
||||||
@@ -573,7 +688,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
execution.push(
|
execution.push(
|
||||||
transaction.saveCheckpoint().then(async () =>
|
transaction.saveCheckpoint().then(() => {
|
||||||
transaction
|
transaction
|
||||||
.handler(
|
.handler(
|
||||||
step.definition.action + "",
|
step.definition.action + "",
|
||||||
@@ -591,12 +706,12 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
|
|
||||||
await setStepFailure(error)
|
await setStepFailure(error)
|
||||||
})
|
})
|
||||||
)
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasSyncSteps && flow.options?.strictCheckpoints) {
|
if (hasSyncSteps && flow.options?.storeExecution) {
|
||||||
await transaction.saveCheckpoint()
|
await transaction.saveCheckpoint()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,16 +745,14 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
flow.state = TransactionState.INVOKING
|
flow.state = TransactionState.INVOKING
|
||||||
flow.startedAt = Date.now()
|
flow.startedAt = Date.now()
|
||||||
|
|
||||||
if (this.options?.storeExecution) {
|
if (this.options?.store) {
|
||||||
await transaction.saveCheckpoint(
|
await transaction.saveCheckpoint(
|
||||||
flow.hasAsyncSteps ? 0 : TransactionOrchestrator.DEFAULT_TTL
|
flow.hasAsyncSteps ? 0 : TransactionOrchestrator.DEFAULT_TTL
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transaction.hasTimeout()) {
|
if (transaction.hasTimeout()) {
|
||||||
await transaction.scheduleTransactionTimeout(
|
await transaction.scheduleTransactionTimeout(transaction.getTimeout()!)
|
||||||
transaction.getTimeoutInterval()!
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit(DistributedTransactionEvent.BEGIN, { transaction })
|
this.emit(DistributedTransactionEvent.BEGIN, { transaction })
|
||||||
@@ -682,12 +795,19 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
this.definition
|
this.definition
|
||||||
)
|
)
|
||||||
|
|
||||||
|
this.options ??= {}
|
||||||
|
|
||||||
const hasAsyncSteps = features.hasAsyncSteps
|
const hasAsyncSteps = features.hasAsyncSteps
|
||||||
const hasStepTimeouts = features.hasStepTimeouts
|
const hasStepTimeouts = features.hasStepTimeouts
|
||||||
const hasRetriesTimeout = features.hasRetriesTimeout
|
const hasRetriesTimeout = features.hasRetriesTimeout
|
||||||
|
const hasTransactionTimeout = !!this.options.timeout
|
||||||
|
|
||||||
this.options ??= {}
|
if (hasAsyncSteps) {
|
||||||
if (hasAsyncSteps || hasStepTimeouts || hasRetriesTimeout) {
|
this.options.store = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasStepTimeouts || hasRetriesTimeout || hasTransactionTimeout) {
|
||||||
|
this.options.store = true
|
||||||
this.options.storeExecution = true
|
this.options.storeExecution = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,6 +819,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
hasFailedSteps: false,
|
hasFailedSteps: false,
|
||||||
hasSkippedSteps: false,
|
hasSkippedSteps: false,
|
||||||
hasWaitingSteps: false,
|
hasWaitingSteps: false,
|
||||||
|
hasRevertedSteps: false,
|
||||||
timedOutAt: null,
|
timedOutAt: null,
|
||||||
state: TransactionState.NOT_STARTED,
|
state: TransactionState.NOT_STARTED,
|
||||||
definition: this.definition,
|
definition: this.definition,
|
||||||
@@ -807,15 +928,16 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
new TransactionStep(),
|
new TransactionStep(),
|
||||||
existingSteps?.[id] || {
|
existingSteps?.[id] || {
|
||||||
id,
|
id,
|
||||||
|
uuid: definitionCopy.uuid,
|
||||||
depth: level.length - 1,
|
depth: level.length - 1,
|
||||||
definition: definitionCopy,
|
definition: definitionCopy,
|
||||||
saveResponse: definitionCopy.saveResponse ?? true,
|
saveResponse: definitionCopy.saveResponse ?? true,
|
||||||
invoke: {
|
invoke: {
|
||||||
state: TransactionState.NOT_STARTED,
|
state: TransactionStepState.NOT_STARTED,
|
||||||
status: TransactionStepStatus.IDLE,
|
status: TransactionStepStatus.IDLE,
|
||||||
},
|
},
|
||||||
compensate: {
|
compensate: {
|
||||||
state: TransactionState.DORMANT,
|
state: TransactionStepState.DORMANT,
|
||||||
status: TransactionStepStatus.IDLE,
|
status: TransactionStepStatus.IDLE,
|
||||||
},
|
},
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
@@ -861,11 +983,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
existingTransaction?.context
|
existingTransaction?.context
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (newTransaction && this.options?.store && this.options?.storeExecution) {
|
||||||
newTransaction &&
|
|
||||||
this.options?.storeExecution &&
|
|
||||||
this.options?.strictCheckpoints
|
|
||||||
) {
|
|
||||||
await transaction.saveCheckpoint(
|
await transaction.saveCheckpoint(
|
||||||
modelFlow.hasAsyncSteps ? 0 : TransactionOrchestrator.DEFAULT_TTL
|
modelFlow.hasAsyncSteps ? 0 : TransactionOrchestrator.DEFAULT_TTL
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MedusaError } from "@medusajs/utils"
|
import { MedusaError, TransactionStepState } from "@medusajs/utils"
|
||||||
import {
|
import {
|
||||||
DistributedTransaction,
|
DistributedTransaction,
|
||||||
TransactionPayload,
|
TransactionPayload,
|
||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
TransactionHandlerType,
|
TransactionHandlerType,
|
||||||
TransactionState,
|
TransactionState,
|
||||||
TransactionStepsDefinition,
|
|
||||||
TransactionStepStatus,
|
TransactionStepStatus,
|
||||||
|
TransactionStepsDefinition,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
export type TransactionStepHandler = (
|
export type TransactionStepHandler = (
|
||||||
@@ -38,14 +38,15 @@ export class TransactionStep {
|
|||||||
*/
|
*/
|
||||||
private stepFailed = false
|
private stepFailed = false
|
||||||
id: string
|
id: string
|
||||||
|
uuid?: string
|
||||||
depth: number
|
depth: number
|
||||||
definition: TransactionStepsDefinition
|
definition: TransactionStepsDefinition
|
||||||
invoke: {
|
invoke: {
|
||||||
state: TransactionState
|
state: TransactionStepState
|
||||||
status: TransactionStepStatus
|
status: TransactionStepStatus
|
||||||
}
|
}
|
||||||
compensate: {
|
compensate: {
|
||||||
state: TransactionState
|
state: TransactionStepState
|
||||||
status: TransactionStepStatus
|
status: TransactionStepStatus
|
||||||
}
|
}
|
||||||
attempts: number
|
attempts: number
|
||||||
@@ -81,24 +82,25 @@ export class TransactionStep {
|
|||||||
return !this.stepFailed
|
return !this.stepFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
public changeState(toState: TransactionState) {
|
public changeState(toState: TransactionStepState) {
|
||||||
const allowed = {
|
const allowed = {
|
||||||
[TransactionState.DORMANT]: [TransactionState.NOT_STARTED],
|
[TransactionStepState.DORMANT]: [TransactionStepState.NOT_STARTED],
|
||||||
[TransactionState.NOT_STARTED]: [
|
[TransactionStepState.NOT_STARTED]: [
|
||||||
TransactionState.INVOKING,
|
TransactionStepState.INVOKING,
|
||||||
TransactionState.COMPENSATING,
|
TransactionStepState.COMPENSATING,
|
||||||
TransactionState.FAILED,
|
TransactionStepState.FAILED,
|
||||||
TransactionState.SKIPPED,
|
TransactionStepState.SKIPPED,
|
||||||
],
|
],
|
||||||
[TransactionState.INVOKING]: [
|
[TransactionStepState.INVOKING]: [
|
||||||
TransactionState.FAILED,
|
TransactionStepState.FAILED,
|
||||||
TransactionState.DONE,
|
TransactionStepState.DONE,
|
||||||
|
TransactionStepState.TIMEOUT,
|
||||||
],
|
],
|
||||||
[TransactionState.COMPENSATING]: [
|
[TransactionStepState.COMPENSATING]: [
|
||||||
TransactionState.REVERTED,
|
TransactionStepState.REVERTED,
|
||||||
TransactionState.FAILED,
|
TransactionStepState.FAILED,
|
||||||
],
|
],
|
||||||
[TransactionState.DONE]: [TransactionState.COMPENSATING],
|
[TransactionStepState.DONE]: [TransactionStepState.COMPENSATING],
|
||||||
}
|
}
|
||||||
|
|
||||||
const curState = this.getStates()
|
const curState = this.getStates()
|
||||||
@@ -155,10 +157,10 @@ export class TransactionStep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasTimeout(): boolean {
|
hasTimeout(): boolean {
|
||||||
return !!this.definition.timeout
|
return !!this.getTimeout()
|
||||||
}
|
}
|
||||||
|
|
||||||
getTimeoutInterval(): number | undefined {
|
getTimeout(): number | undefined {
|
||||||
return this.definition.timeout
|
return this.definition.timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +192,7 @@ export class TransactionStep {
|
|||||||
const { status, state } = this.getStates()
|
const { status, state } = this.getStates()
|
||||||
return (
|
return (
|
||||||
(!this.isCompensating() &&
|
(!this.isCompensating() &&
|
||||||
state === TransactionState.NOT_STARTED &&
|
state === TransactionStepState.NOT_STARTED &&
|
||||||
flowState === TransactionState.INVOKING) ||
|
flowState === TransactionState.INVOKING) ||
|
||||||
status === TransactionStepStatus.TEMPORARY_FAILURE
|
status === TransactionStepStatus.TEMPORARY_FAILURE
|
||||||
)
|
)
|
||||||
@@ -199,7 +201,7 @@ export class TransactionStep {
|
|||||||
canCompensate(flowState: TransactionState): boolean {
|
canCompensate(flowState: TransactionState): boolean {
|
||||||
return (
|
return (
|
||||||
this.isCompensating() &&
|
this.isCompensating() &&
|
||||||
this.getStates().state === TransactionState.NOT_STARTED &&
|
this.getStates().state === TransactionStepState.NOT_STARTED &&
|
||||||
flowState === TransactionState.COMPENSATING
|
flowState === TransactionState.COMPENSATING
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,118 @@
|
|||||||
import { DistributedTransaction } from "./distributed-transaction"
|
import { DistributedTransaction } from "./distributed-transaction"
|
||||||
import { TransactionStep } from "./transaction-step"
|
import { TransactionStep } from "./transaction-step"
|
||||||
|
export {
|
||||||
|
TransactionHandlerType,
|
||||||
|
TransactionState,
|
||||||
|
TransactionStepStatus,
|
||||||
|
} from "@medusajs/utils"
|
||||||
|
|
||||||
export enum TransactionHandlerType {
|
/**
|
||||||
INVOKE = "invoke",
|
* Defines the structure and behavior of a single step within a transaction workflow.
|
||||||
COMPENSATE = "compensate",
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
export type TransactionStepsDefinition = {
|
export type TransactionStepsDefinition = {
|
||||||
|
/**
|
||||||
|
* A unique identifier for the transaction step.
|
||||||
|
* This is set automatically when declaring a workflow with "createWorkflow"
|
||||||
|
*/
|
||||||
|
uuid?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the action to be performed in this step.
|
||||||
|
* "name" is an alias for action when creating a workflow with "createWorkflow".
|
||||||
|
*/
|
||||||
action?: string
|
action?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether the workflow should continue even if there is a permanent failure in this step.
|
||||||
|
* In case it is set to true, the children steps of this step will not be executed and their status will be marked as TransactionStepState.SKIPPED.
|
||||||
|
*/
|
||||||
continueOnPermanentFailure?: boolean
|
continueOnPermanentFailure?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, no compensation action will be triggered for this step in case of a failure.
|
||||||
|
*/
|
||||||
noCompensation?: boolean
|
noCompensation?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum number of times this step should be retried in case of temporary failures.
|
||||||
|
* The default is 0 (no retries).
|
||||||
|
*/
|
||||||
maxRetries?: number
|
maxRetries?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interval (in seconds) between retry attempts after a temporary failure.
|
||||||
|
* The default is to retry immediately.
|
||||||
|
*/
|
||||||
retryInterval?: number
|
retryInterval?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interval (in seconds) to retry a step even if its status is "TransactionStepStatus.WAITING".
|
||||||
|
*/
|
||||||
retryIntervalAwaiting?: number
|
retryIntervalAwaiting?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum amount of time (in seconds) to wait for this step to complete.
|
||||||
|
* This is NOT an execution timeout, the step will always be executed and wait for its response.
|
||||||
|
* If the response is not received within the timeout set, it will be marked as "TransactionStepStatus.TIMEOUT" and the workflow will be reverted as soon as it receives the response.
|
||||||
|
*/
|
||||||
timeout?: number
|
timeout?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, the step is executed asynchronously. This means that the workflow will not wait for the response of this step.
|
||||||
|
* Async steps require to have their responses set using "setStepSuccess" or "setStepFailure".
|
||||||
|
* If combined with a timeout, and any response is not set within that interval, the step will be marked as "TransactionStepStatus.TIMEOUT" and the workflow will be reverted immediately.
|
||||||
|
*/
|
||||||
async?: boolean
|
async?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, the compensation function for this step is executed asynchronously. Which means, the response has to be set using "setStepSuccess" or "setStepFailure".
|
||||||
|
*/
|
||||||
compensateAsync?: boolean
|
compensateAsync?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, the workflow will not wait for their sibling steps to complete before moving to the next step.
|
||||||
|
*/
|
||||||
noWait?: boolean
|
noWait?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, the response of this step will be stored.
|
||||||
|
* Default is true.
|
||||||
|
*/
|
||||||
saveResponse?: boolean
|
saveResponse?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the next step(s) to execute after this step. Can be a single step or an array of steps.
|
||||||
|
*/
|
||||||
next?: TransactionStepsDefinition | TransactionStepsDefinition[]
|
next?: TransactionStepsDefinition | TransactionStepsDefinition[]
|
||||||
|
|
||||||
|
// TODO: add metadata field for customizations
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TransactionStepStatus {
|
/**
|
||||||
IDLE = "idle",
|
* Defines the options for a transaction model, which are applicable to the entire workflow.
|
||||||
OK = "ok",
|
*/
|
||||||
WAITING = "waiting_response",
|
|
||||||
TEMPORARY_FAILURE = "temp_failure",
|
|
||||||
PERMANENT_FAILURE = "permanent_failure",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum TransactionState {
|
|
||||||
NOT_STARTED = "not_started",
|
|
||||||
INVOKING = "invoking",
|
|
||||||
WAITING_TO_COMPENSATE = "waiting_to_compensate",
|
|
||||||
COMPENSATING = "compensating",
|
|
||||||
DONE = "done",
|
|
||||||
REVERTED = "reverted",
|
|
||||||
FAILED = "failed",
|
|
||||||
DORMANT = "dormant",
|
|
||||||
SKIPPED = "skipped",
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TransactionModelOptions = {
|
export type TransactionModelOptions = {
|
||||||
|
/**
|
||||||
|
* The global timeout for the entire transaction workflow (in seconds).
|
||||||
|
*/
|
||||||
timeout?: number
|
timeout?: number
|
||||||
storeExecution?: boolean
|
|
||||||
|
/**
|
||||||
|
* If true, the state of the transaction will be persisted.
|
||||||
|
*/
|
||||||
|
store?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TBD
|
||||||
|
*/
|
||||||
retentionTime?: number
|
retentionTime?: number
|
||||||
strictCheckpoints?: boolean
|
|
||||||
|
/**
|
||||||
|
* If true, the execution details of each step will be stored.
|
||||||
|
*/
|
||||||
|
storeExecution?: boolean
|
||||||
|
|
||||||
|
// TODO: add metadata field for customizations
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TransactionModel = {
|
export type TransactionModel = {
|
||||||
|
|||||||
@@ -81,9 +81,16 @@ export class WorkflowManager {
|
|||||||
const finalFlow = flow instanceof OrchestratorBuilder ? flow.build() : flow
|
const finalFlow = flow instanceof OrchestratorBuilder ? flow.build() : flow
|
||||||
|
|
||||||
if (WorkflowManager.workflows.has(workflowId)) {
|
if (WorkflowManager.workflows.has(workflowId)) {
|
||||||
|
function excludeStepUuid(key, value) {
|
||||||
|
return key === "uuid" ? undefined : value
|
||||||
|
}
|
||||||
|
|
||||||
const areStepsEqual = finalFlow
|
const areStepsEqual = finalFlow
|
||||||
? JSON.stringify(finalFlow) ===
|
? JSON.stringify(finalFlow, excludeStepUuid) ===
|
||||||
JSON.stringify(WorkflowManager.workflows.get(workflowId)!.flow_)
|
JSON.stringify(
|
||||||
|
WorkflowManager.workflows.get(workflowId)!.flow_,
|
||||||
|
excludeStepUuid
|
||||||
|
)
|
||||||
: true
|
: true
|
||||||
|
|
||||||
if (!areStepsEqual) {
|
if (!areStepsEqual) {
|
||||||
@@ -131,14 +138,19 @@ export class WorkflowManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const finalFlow = flow instanceof OrchestratorBuilder ? flow.build() : flow
|
const finalFlow = flow instanceof OrchestratorBuilder ? flow.build() : flow
|
||||||
|
const updatedOptions = { ...workflow.options, ...options }
|
||||||
|
|
||||||
WorkflowManager.workflows.set(workflowId, {
|
WorkflowManager.workflows.set(workflowId, {
|
||||||
id: workflowId,
|
id: workflowId,
|
||||||
flow_: finalFlow,
|
flow_: finalFlow,
|
||||||
orchestrator: new TransactionOrchestrator(workflowId, finalFlow, options),
|
orchestrator: new TransactionOrchestrator(
|
||||||
|
workflowId,
|
||||||
|
finalFlow,
|
||||||
|
updatedOptions
|
||||||
|
),
|
||||||
handler: WorkflowManager.buildHandlers(workflow.handlers_),
|
handler: WorkflowManager.buildHandlers(workflow.handlers_),
|
||||||
handlers_: workflow.handlers_,
|
handlers_: workflow.handlers_,
|
||||||
options: { ...workflow.options, ...options },
|
options: updatedOptions,
|
||||||
requiredModules,
|
requiredModules,
|
||||||
optionalModules,
|
optionalModules,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,4 +18,3 @@ export * as SearchTypes from "./search"
|
|||||||
export * as StockLocationTypes from "./stock-location"
|
export * as StockLocationTypes from "./stock-location"
|
||||||
export * as TransactionBaseTypes from "./transaction-base"
|
export * as TransactionBaseTypes from "./transaction-base"
|
||||||
export * as WorkflowTypes from "./workflow"
|
export * as WorkflowTypes from "./workflow"
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export interface RepositoryService<
|
|||||||
|
|
||||||
update(data: TDTOs["update"][], context?: Context): Promise<T[]>
|
update(data: TDTOs["update"][], context?: Context): Promise<T[]>
|
||||||
|
|
||||||
delete(ids: string[], context?: Context): Promise<void>
|
delete(idsOrPKs: string[] | object[], context?: Context): Promise<void>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Soft delete entities and cascade to related entities if configured.
|
* Soft delete entities and cascade to related entities if configured.
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ export * from "./joiner"
|
|||||||
export * from "./link-modules"
|
export * from "./link-modules"
|
||||||
export * from "./logger"
|
export * from "./logger"
|
||||||
export * from "./modules-sdk"
|
export * from "./modules-sdk"
|
||||||
|
export * from "./payment"
|
||||||
export * from "./pricing"
|
export * from "./pricing"
|
||||||
export * from "./product"
|
export * from "./product"
|
||||||
export * from "./payment"
|
|
||||||
export * from "./product-category"
|
export * from "./product-category"
|
||||||
export * from "./promotion"
|
export * from "./promotion"
|
||||||
export * from "./region"
|
export * from "./region"
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export async function mikroOrmCreateConnection(
|
|||||||
// It is important that the knex package version is the same as the one used by MikroORM knex package
|
// It is important that the knex package version is the same as the one used by MikroORM knex package
|
||||||
driverOptions = database.connection
|
driverOptions = database.connection
|
||||||
clientUrl =
|
clientUrl =
|
||||||
database.connection.context.client.config.connection.connectionString
|
database.connection.context?.client?.config?.connection?.connectionString
|
||||||
schema = database.connection.context.client.config.searchPath
|
schema = database.connection.context?.client?.config?.searchPath
|
||||||
}
|
}
|
||||||
|
|
||||||
const { MikroORM } = await import("@mikro-orm/postgresql")
|
const { MikroORM } = await import("@mikro-orm/postgresql")
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export * from "./inject-transaction-manager"
|
|
||||||
export * from "./inject-manager"
|
export * from "./inject-manager"
|
||||||
export * from "./inject-shared-context"
|
export * from "./inject-shared-context"
|
||||||
|
export * from "./inject-transaction-manager"
|
||||||
@@ -93,7 +93,7 @@ export function loadDatabaseConfig(
|
|||||||
database.connection = options.database!.connection
|
database.connection = options.database!.connection
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!database.clientUrl && !silent) {
|
if (!database.clientUrl && !silent && !database.connection) {
|
||||||
throw new MedusaError(
|
throw new MedusaError(
|
||||||
MedusaError.Types.INVALID_ARGUMENT,
|
MedusaError.Types.INVALID_ARGUMENT,
|
||||||
"No database clientUrl provided. Please provide the clientUrl through the [MODULE]_POSTGRES_URL, MEDUSA_POSTGRES_URL or POSTGRES_URL environment variable or the options object in the initialize function."
|
"No database clientUrl provided. Please provide the clientUrl through the [MODULE]_POSTGRES_URL, MEDUSA_POSTGRES_URL or POSTGRES_URL environment variable or the options object in the initialize function."
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./symbol"
|
export * from "./symbol"
|
||||||
|
export * from "./types"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export enum TransactionHandlerType {
|
||||||
|
INVOKE = "invoke",
|
||||||
|
COMPENSATE = "compensate",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TransactionStepStatus {
|
||||||
|
IDLE = "idle",
|
||||||
|
OK = "ok",
|
||||||
|
WAITING = "waiting_response",
|
||||||
|
TEMPORARY_FAILURE = "temp_failure",
|
||||||
|
PERMANENT_FAILURE = "permanent_failure",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TransactionState {
|
||||||
|
NOT_STARTED = "not_started",
|
||||||
|
INVOKING = "invoking",
|
||||||
|
WAITING_TO_COMPENSATE = "waiting_to_compensate",
|
||||||
|
COMPENSATING = "compensating",
|
||||||
|
DONE = "done",
|
||||||
|
REVERTED = "reverted",
|
||||||
|
FAILED = "failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TransactionStepState {
|
||||||
|
NOT_STARTED = "not_started",
|
||||||
|
INVOKING = "invoking",
|
||||||
|
COMPENSATING = "compensating",
|
||||||
|
DONE = "done",
|
||||||
|
REVERTED = "reverted",
|
||||||
|
FAILED = "failed",
|
||||||
|
DORMANT = "dormant",
|
||||||
|
SKIPPED = "skipped",
|
||||||
|
TIMEOUT = "timeout",
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/dist
|
||||||
|
node_modules
|
||||||
|
.DS_store
|
||||||
|
.env*
|
||||||
|
.env
|
||||||
|
*.sql
|
||||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
# Workflow Orchestrator
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./workflow_1"
|
||||||
|
export * from "./workflow_2"
|
||||||
|
export * from "./workflow_step_timeout"
|
||||||
|
export * from "./workflow_transaction_timeout"
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
StepResponse,
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
} from "@medusajs/workflows-sdk"
|
||||||
|
|
||||||
|
const step_1 = createStep(
|
||||||
|
"step_1",
|
||||||
|
jest.fn((input) => {
|
||||||
|
input.test = "test"
|
||||||
|
return new StepResponse(input, { compensate: 123 })
|
||||||
|
}),
|
||||||
|
jest.fn((compensateInput) => {
|
||||||
|
if (!compensateInput) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("reverted", compensateInput.compensate)
|
||||||
|
return new StepResponse({
|
||||||
|
reverted: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const step_2 = createStep(
|
||||||
|
"step_2",
|
||||||
|
jest.fn((input, context) => {
|
||||||
|
console.log("triggered async request", context.metadata.idempotency_key)
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
return new StepResponse({ notAsyncResponse: input.hey })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
jest.fn((_, context) => {
|
||||||
|
return new StepResponse({
|
||||||
|
step: context.metadata.action,
|
||||||
|
idempotency_key: context.metadata.idempotency_key,
|
||||||
|
reverted: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const step_3 = createStep(
|
||||||
|
"step_3",
|
||||||
|
jest.fn((res) => {
|
||||||
|
return new StepResponse({
|
||||||
|
done: {
|
||||||
|
inputFromSyncStep: res.notAsyncResponse,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
createWorkflow("workflow_1", function (input) {
|
||||||
|
step_1(input)
|
||||||
|
|
||||||
|
const ret2 = step_2({ hey: "oh" })
|
||||||
|
|
||||||
|
step_2({ hey: "async hello" }).config({
|
||||||
|
name: "new_step_name",
|
||||||
|
async: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return step_3(ret2)
|
||||||
|
})
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
StepResponse,
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
} from "@medusajs/workflows-sdk"
|
||||||
|
|
||||||
|
const step_1 = createStep(
|
||||||
|
"step_1",
|
||||||
|
jest.fn((input) => {
|
||||||
|
input.test = "test"
|
||||||
|
return new StepResponse(input, { compensate: 123 })
|
||||||
|
}),
|
||||||
|
jest.fn((compensateInput) => {
|
||||||
|
if (!compensateInput) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("reverted", compensateInput.compensate)
|
||||||
|
return new StepResponse({
|
||||||
|
reverted: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const step_2 = createStep(
|
||||||
|
"step_2",
|
||||||
|
jest.fn((input, context) => {
|
||||||
|
console.log("triggered async request", context.metadata.idempotency_key)
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
return new StepResponse({ notAsyncResponse: input.hey })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
jest.fn((_, context) => {
|
||||||
|
return new StepResponse({
|
||||||
|
step: context.metadata.action,
|
||||||
|
idempotency_key: context.metadata.idempotency_key,
|
||||||
|
reverted: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const step_3 = createStep(
|
||||||
|
"step_3",
|
||||||
|
jest.fn((res) => {
|
||||||
|
return new StepResponse({
|
||||||
|
done: {
|
||||||
|
inputFromSyncStep: res.notAsyncResponse,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
createWorkflow(
|
||||||
|
{
|
||||||
|
name: "workflow_2",
|
||||||
|
retentionTime: 1000,
|
||||||
|
},
|
||||||
|
function (input) {
|
||||||
|
step_1(input)
|
||||||
|
|
||||||
|
const ret2 = step_2({ hey: "oh" })
|
||||||
|
|
||||||
|
step_2({ hey: "async hello" }).config({
|
||||||
|
name: "new_step_name",
|
||||||
|
async: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return step_3(ret2)
|
||||||
|
}
|
||||||
|
)
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
StepResponse,
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
} from "@medusajs/workflows-sdk"
|
||||||
|
import { setTimeout } from "timers/promises"
|
||||||
|
|
||||||
|
const step_1 = createStep(
|
||||||
|
"step_1",
|
||||||
|
jest.fn(async (input) => {
|
||||||
|
await setTimeout(200)
|
||||||
|
|
||||||
|
return new StepResponse(input, { compensate: 123 })
|
||||||
|
}),
|
||||||
|
jest.fn(() => {})
|
||||||
|
)
|
||||||
|
|
||||||
|
createWorkflow(
|
||||||
|
{
|
||||||
|
name: "workflow_step_timeout",
|
||||||
|
},
|
||||||
|
function (input) {
|
||||||
|
const resp = step_1(input).config({
|
||||||
|
timeout: 0.1, // 0.1 second
|
||||||
|
})
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
)
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
StepResponse,
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
} from "@medusajs/workflows-sdk"
|
||||||
|
|
||||||
|
const step_1 = createStep(
|
||||||
|
"step_1",
|
||||||
|
jest.fn((input) => {
|
||||||
|
input.test = "test"
|
||||||
|
return new StepResponse(input, { compensate: 123 })
|
||||||
|
}),
|
||||||
|
jest.fn((compensateInput) => {
|
||||||
|
if (!compensateInput) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StepResponse({
|
||||||
|
reverted: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
createWorkflow(
|
||||||
|
{
|
||||||
|
name: "workflow_transaction_timeout",
|
||||||
|
timeout: 0.1, // 0.1 second
|
||||||
|
},
|
||||||
|
function (input) {
|
||||||
|
const resp = step_1(input).config({
|
||||||
|
async: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { MedusaApp } from "@medusajs/modules-sdk"
|
||||||
|
import { RemoteJoinerQuery } from "@medusajs/types"
|
||||||
|
import { TransactionHandlerType } from "@medusajs/utils"
|
||||||
|
import { IWorkflowsModuleService } from "@medusajs/workflows-sdk"
|
||||||
|
import { knex } from "knex"
|
||||||
|
import { setTimeout } from "timers/promises"
|
||||||
|
import "../__fixtures__"
|
||||||
|
import { DB_URL, TestDatabase } from "../utils"
|
||||||
|
|
||||||
|
const sharedPgConnection = knex<any, any>({
|
||||||
|
client: "pg",
|
||||||
|
searchPath: process.env.MEDUSA_WORKFLOW_ENGINE_DB_SCHEMA,
|
||||||
|
connection: {
|
||||||
|
connectionString: DB_URL,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const afterEach_ = async () => {
|
||||||
|
await TestDatabase.clearTables(sharedPgConnection)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Workflow Orchestrator module", function () {
|
||||||
|
describe("Testing basic workflow", function () {
|
||||||
|
let workflowOrcModule: IWorkflowsModuleService
|
||||||
|
let query: (
|
||||||
|
query: string | RemoteJoinerQuery | object,
|
||||||
|
variables?: Record<string, unknown>
|
||||||
|
) => Promise<any>
|
||||||
|
|
||||||
|
afterEach(afterEach_)
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const {
|
||||||
|
runMigrations,
|
||||||
|
query: remoteQuery,
|
||||||
|
modules,
|
||||||
|
} = await MedusaApp({
|
||||||
|
sharedResourcesConfig: {
|
||||||
|
database: {
|
||||||
|
connection: sharedPgConnection,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modulesConfig: {
|
||||||
|
workflows: {
|
||||||
|
resolve: __dirname + "/../..",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
query = remoteQuery
|
||||||
|
|
||||||
|
await runMigrations()
|
||||||
|
|
||||||
|
workflowOrcModule =
|
||||||
|
modules.workflows as unknown as IWorkflowsModuleService
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(afterEach_)
|
||||||
|
|
||||||
|
it("should return a list of workflow executions and remove after completed when there is no retentionTime set", async () => {
|
||||||
|
await workflowOrcModule.run("workflow_1", {
|
||||||
|
input: {
|
||||||
|
value: "123",
|
||||||
|
},
|
||||||
|
throwOnError: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
let executionsList = await query({
|
||||||
|
workflow_executions: {
|
||||||
|
fields: ["workflow_id", "transaction_id", "state"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executionsList).toHaveLength(1)
|
||||||
|
|
||||||
|
const { result } = await workflowOrcModule.setStepSuccess({
|
||||||
|
idempotencyKey: {
|
||||||
|
action: TransactionHandlerType.INVOKE,
|
||||||
|
stepId: "new_step_name",
|
||||||
|
workflowId: "workflow_1",
|
||||||
|
transactionId: executionsList[0].transaction_id,
|
||||||
|
},
|
||||||
|
stepResponse: { uhuuuu: "yeaah!" },
|
||||||
|
})
|
||||||
|
|
||||||
|
executionsList = await query({
|
||||||
|
workflow_executions: {
|
||||||
|
fields: ["id"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executionsList).toHaveLength(0)
|
||||||
|
expect(result).toEqual({
|
||||||
|
done: {
|
||||||
|
inputFromSyncStep: "oh",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return a list of workflow executions and keep it saved when there is a retentionTime set", async () => {
|
||||||
|
await workflowOrcModule.run("workflow_2", {
|
||||||
|
input: {
|
||||||
|
value: "123",
|
||||||
|
},
|
||||||
|
throwOnError: true,
|
||||||
|
transactionId: "transaction_1",
|
||||||
|
})
|
||||||
|
|
||||||
|
let executionsList = await query({
|
||||||
|
workflow_executions: {
|
||||||
|
fields: ["id"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executionsList).toHaveLength(1)
|
||||||
|
|
||||||
|
await workflowOrcModule.setStepSuccess({
|
||||||
|
idempotencyKey: {
|
||||||
|
action: TransactionHandlerType.INVOKE,
|
||||||
|
stepId: "new_step_name",
|
||||||
|
workflowId: "workflow_2",
|
||||||
|
transactionId: "transaction_1",
|
||||||
|
},
|
||||||
|
stepResponse: { uhuuuu: "yeaah!" },
|
||||||
|
})
|
||||||
|
|
||||||
|
executionsList = await query({
|
||||||
|
workflow_executions: {
|
||||||
|
fields: ["id"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executionsList).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should revert the entire transaction when a step timeout expires", async () => {
|
||||||
|
const { transaction } = await workflowOrcModule.run(
|
||||||
|
"workflow_step_timeout",
|
||||||
|
{
|
||||||
|
input: {},
|
||||||
|
throwOnError: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(transaction.flow.state).toEqual("reverted")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should revert the entire transaction when the transaction timeout expires", async () => {
|
||||||
|
const { transaction } = await workflowOrcModule.run(
|
||||||
|
"workflow_transaction_timeout",
|
||||||
|
{
|
||||||
|
input: {},
|
||||||
|
throwOnError: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await setTimeout(200)
|
||||||
|
|
||||||
|
expect(transaction.flow.state).toEqual("reverted")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
if (typeof process.env.DB_TEMP_NAME === "undefined") {
|
||||||
|
const tempName = parseInt(process.env.JEST_WORKER_ID || "1")
|
||||||
|
process.env.DB_TEMP_NAME = `medusa-workflow-engine-inmemory-${tempName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.MEDUSA_WORKFLOW_ENGINE_DB_SCHEMA = "public"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { JestUtils } from "medusa-test-utils"
|
||||||
|
|
||||||
|
JestUtils.afterAllHookDropDatabase()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as process from "process"
|
||||||
|
|
||||||
|
const DB_HOST = process.env.DB_HOST ?? "localhost"
|
||||||
|
const DB_USERNAME = process.env.DB_USERNAME ?? ""
|
||||||
|
const DB_PASSWORD = process.env.DB_PASSWORD
|
||||||
|
const DB_NAME = process.env.DB_TEMP_NAME
|
||||||
|
|
||||||
|
export const DB_URL = `postgres://${DB_USERNAME}${
|
||||||
|
DB_PASSWORD ? `:${DB_PASSWORD}` : ""
|
||||||
|
}@${DB_HOST}/${DB_NAME}`
|
||||||
|
|
||||||
|
interface TestDatabase {
|
||||||
|
clearTables(knex): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TestDatabase: TestDatabase = {
|
||||||
|
clearTables: async (knex) => {
|
||||||
|
await knex.raw(`
|
||||||
|
TRUNCATE TABLE workflow_execution CASCADE;
|
||||||
|
`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./database"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
module.exports = {
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^@models": "<rootDir>/src/models",
|
||||||
|
"^@services": "<rootDir>/src/services",
|
||||||
|
"^@repositories": "<rootDir>/src/repositories",
|
||||||
|
"^@types": "<rootDir>/src/types",
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
"^.+\\.[jt]s?$": [
|
||||||
|
"ts-jest",
|
||||||
|
{
|
||||||
|
tsConfig: "tsconfig.spec.json",
|
||||||
|
isolatedModules: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
testEnvironment: `node`,
|
||||||
|
moduleFileExtensions: [`js`, `ts`],
|
||||||
|
modulePathIgnorePatterns: ["dist/"],
|
||||||
|
setupFiles: ["<rootDir>/integration-tests/setup-env.js"],
|
||||||
|
setupFilesAfterEnv: ["<rootDir>/integration-tests/setup.js"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import * as entities from "./src/models"
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entities: Object.values(entities),
|
||||||
|
schema: "public",
|
||||||
|
clientUrl: "postgres://postgres@localhost/medusa-workflow-engine-inmemory",
|
||||||
|
type: "postgresql",
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "@medusajs/workflow-engine-inmemory",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Medusa Workflow Orchestrator module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/medusajs/medusa",
|
||||||
|
"directory": "packages/workflow-engine-inmemory"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"author": "Medusa",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"watch": "tsc --build --watch",
|
||||||
|
"watch:test": "tsc --build tsconfig.spec.json --watch",
|
||||||
|
"prepublishOnly": "cross-env NODE_ENV=production tsc --build && tsc-alias -p tsconfig.json",
|
||||||
|
"build": "rimraf dist && tsc --build && tsc-alias -p tsconfig.json",
|
||||||
|
"test": "jest --passWithNoTests --runInBand --bail --forceExit -- src/**/__tests__/**/*.ts",
|
||||||
|
"test:integration": "jest --runInBand --forceExit -- integration-tests/**/__tests__/**/*.ts",
|
||||||
|
"migration:generate": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:generate",
|
||||||
|
"migration:initial": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create --initial",
|
||||||
|
"migration:create": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create",
|
||||||
|
"migration:up": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:up",
|
||||||
|
"orm:cache:clear": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm cache:clear"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mikro-orm/cli": "5.9.7",
|
||||||
|
"cross-env": "^5.2.1",
|
||||||
|
"jest": "^29.6.3",
|
||||||
|
"medusa-test-utils": "^1.1.40",
|
||||||
|
"rimraf": "^3.0.2",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tsc-alias": "^1.8.6",
|
||||||
|
"typescript": "^5.1.6"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@medusajs/modules-sdk": "^1.12.5",
|
||||||
|
"@medusajs/types": "^1.11.9",
|
||||||
|
"@medusajs/utils": "^1.11.2",
|
||||||
|
"@medusajs/workflows-sdk": "^0.1.0",
|
||||||
|
"@mikro-orm/core": "5.9.7",
|
||||||
|
"@mikro-orm/migrations": "5.9.7",
|
||||||
|
"@mikro-orm/postgresql": "5.9.7",
|
||||||
|
"awilix": "^8.0.0",
|
||||||
|
"dotenv": "^16.1.4",
|
||||||
|
"knex": "2.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Modules } from "@medusajs/modules-sdk"
|
||||||
|
import { ModulesSdkUtils } from "@medusajs/utils"
|
||||||
|
import * as models from "@models"
|
||||||
|
import { moduleDefinition } from "./module-definition"
|
||||||
|
|
||||||
|
export default moduleDefinition
|
||||||
|
|
||||||
|
const migrationScriptOptions = {
|
||||||
|
moduleName: Modules.WORKFLOW_ENGINE,
|
||||||
|
models: models,
|
||||||
|
pathToMigrations: __dirname + "/migrations",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runMigrations = ModulesSdkUtils.buildMigrationScript(
|
||||||
|
migrationScriptOptions
|
||||||
|
)
|
||||||
|
export const revertMigration = ModulesSdkUtils.buildRevertMigrationScript(
|
||||||
|
migrationScriptOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
export * from "./initialize"
|
||||||
|
export * from "./loaders"
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
ExternalModuleDeclaration,
|
||||||
|
InternalModuleDeclaration,
|
||||||
|
MedusaModule,
|
||||||
|
MODULE_PACKAGE_NAMES,
|
||||||
|
Modules,
|
||||||
|
} from "@medusajs/modules-sdk"
|
||||||
|
import { ModulesSdkTypes } from "@medusajs/types"
|
||||||
|
import { WorkflowOrchestratorTypes } from "@medusajs/workflows-sdk"
|
||||||
|
import { moduleDefinition } from "../module-definition"
|
||||||
|
import { InitializeModuleInjectableDependencies } from "../types"
|
||||||
|
|
||||||
|
export const initialize = async (
|
||||||
|
options?:
|
||||||
|
| ModulesSdkTypes.ModuleServiceInitializeOptions
|
||||||
|
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
|
||||||
|
| ExternalModuleDeclaration
|
||||||
|
| InternalModuleDeclaration,
|
||||||
|
injectedDependencies?: InitializeModuleInjectableDependencies
|
||||||
|
): Promise<WorkflowOrchestratorTypes.IWorkflowsModuleService> => {
|
||||||
|
const loaded =
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
await MedusaModule.bootstrap<WorkflowOrchestratorTypes.IWorkflowsModuleService>(
|
||||||
|
{
|
||||||
|
moduleKey: Modules.WORKFLOW_ENGINE,
|
||||||
|
defaultPath: MODULE_PACKAGE_NAMES[Modules.WORKFLOW_ENGINE],
|
||||||
|
declaration: options as
|
||||||
|
| InternalModuleDeclaration
|
||||||
|
| ExternalModuleDeclaration,
|
||||||
|
injectedDependencies,
|
||||||
|
moduleExports: moduleDefinition,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return loaded[Modules.WORKFLOW_ENGINE]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Modules } from "@medusajs/modules-sdk"
|
||||||
|
import { ModuleJoinerConfig } from "@medusajs/types"
|
||||||
|
import { MapToConfig } from "@medusajs/utils"
|
||||||
|
import { WorkflowExecution } from "@models"
|
||||||
|
import moduleSchema from "./schema"
|
||||||
|
|
||||||
|
export const LinkableKeys = {
|
||||||
|
workflow_execution_id: WorkflowExecution.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityLinkableKeysMap: MapToConfig = {}
|
||||||
|
Object.entries(LinkableKeys).forEach(([key, value]) => {
|
||||||
|
entityLinkableKeysMap[value] ??= []
|
||||||
|
entityLinkableKeysMap[value].push({
|
||||||
|
mapTo: key,
|
||||||
|
valueFrom: key.split("_").pop()!,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const entityNameToLinkableKeysMap: MapToConfig = entityLinkableKeysMap
|
||||||
|
|
||||||
|
export const joinerConfig: ModuleJoinerConfig = {
|
||||||
|
serviceName: Modules.WORKFLOW_ENGINE,
|
||||||
|
primaryKeys: ["id"],
|
||||||
|
schema: moduleSchema,
|
||||||
|
linkableKeys: LinkableKeys,
|
||||||
|
alias: {
|
||||||
|
name: ["workflow_execution", "workflow_executions"],
|
||||||
|
args: {
|
||||||
|
entity: WorkflowExecution.name,
|
||||||
|
methodSuffix: "WorkflowExecution",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
InternalModuleDeclaration,
|
||||||
|
LoaderOptions,
|
||||||
|
Modules,
|
||||||
|
} from "@medusajs/modules-sdk"
|
||||||
|
import { ModulesSdkTypes } from "@medusajs/types"
|
||||||
|
import { ModulesSdkUtils } from "@medusajs/utils"
|
||||||
|
import { EntitySchema } from "@mikro-orm/core"
|
||||||
|
import * as WorkflowOrchestratorModels from "../models"
|
||||||
|
|
||||||
|
export default async (
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
container,
|
||||||
|
logger,
|
||||||
|
}: LoaderOptions<
|
||||||
|
| ModulesSdkTypes.ModuleServiceInitializeOptions
|
||||||
|
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
|
||||||
|
>,
|
||||||
|
moduleDeclaration?: InternalModuleDeclaration
|
||||||
|
): Promise<void> => {
|
||||||
|
const entities = Object.values(
|
||||||
|
WorkflowOrchestratorModels
|
||||||
|
) as unknown as EntitySchema[]
|
||||||
|
const pathToMigrations = __dirname + "/../migrations"
|
||||||
|
|
||||||
|
await ModulesSdkUtils.mikroOrmConnectionLoader({
|
||||||
|
moduleName: Modules.WORKFLOW_ENGINE,
|
||||||
|
entities,
|
||||||
|
container,
|
||||||
|
options,
|
||||||
|
moduleDeclaration,
|
||||||
|
logger,
|
||||||
|
pathToMigrations,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { MikroOrmBaseRepository, ModulesSdkUtils } from "@medusajs/utils"
|
||||||
|
import * as ModuleModels from "@models"
|
||||||
|
import * as ModuleServices from "@services"
|
||||||
|
|
||||||
|
export default ModulesSdkUtils.moduleContainerLoaderFactory({
|
||||||
|
moduleModels: ModuleModels,
|
||||||
|
moduleServices: ModuleServices,
|
||||||
|
moduleRepositories: { BaseRepository: MikroOrmBaseRepository },
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./connection"
|
||||||
|
export * from "./container"
|
||||||
|
export * from "./utils"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { asClass } from "awilix"
|
||||||
|
import { InMemoryDistributedTransactionStorage } from "../utils"
|
||||||
|
|
||||||
|
export default async ({ container }): Promise<void> => {
|
||||||
|
container.register({
|
||||||
|
inMemoryDistributedTransactionStorage: asClass(
|
||||||
|
InMemoryDistributedTransactionStorage
|
||||||
|
).singleton(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Migration } from "@mikro-orm/migrations"
|
||||||
|
|
||||||
|
export class Migration20231221104256 extends Migration {
|
||||||
|
async up(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_execution
|
||||||
|
(
|
||||||
|
id character varying NOT NULL,
|
||||||
|
workflow_id character varying NOT NULL,
|
||||||
|
transaction_id character varying NOT NULL,
|
||||||
|
execution jsonb NULL,
|
||||||
|
context jsonb NULL,
|
||||||
|
state character varying NOT NULL,
|
||||||
|
created_at timestamp WITHOUT time zone NOT NULL DEFAULT Now(),
|
||||||
|
updated_at timestamp WITHOUT time zone NOT NULL DEFAULT Now(),
|
||||||
|
deleted_at timestamp WITHOUT time zone NULL,
|
||||||
|
CONSTRAINT "PK_workflow_execution_workflow_id_transaction_id" PRIMARY KEY ("workflow_id", "transaction_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_workflow_execution_id" ON "workflow_execution" ("id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_workflow_id" ON "workflow_execution" ("workflow_id") WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_transaction_id" ON "workflow_execution" ("transaction_id") WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_state" ON "workflow_execution" ("state") WHERE deleted_at IS NULL;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
`
|
||||||
|
DROP INDEX "IDX_workflow_execution_id";
|
||||||
|
DROP INDEX "IDX_workflow_execution_workflow_id";
|
||||||
|
DROP INDEX "IDX_workflow_execution_transaction_id";
|
||||||
|
DROP INDEX "IDX_workflow_execution_state";
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS workflow_execution;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as WorkflowExecution } from "./workflow-execution"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { TransactionState } from "@medusajs/orchestration"
|
||||||
|
import { DALUtils, generateEntityId } from "@medusajs/utils"
|
||||||
|
import {
|
||||||
|
BeforeCreate,
|
||||||
|
Entity,
|
||||||
|
Enum,
|
||||||
|
Filter,
|
||||||
|
Index,
|
||||||
|
OnInit,
|
||||||
|
OptionalProps,
|
||||||
|
PrimaryKey,
|
||||||
|
Property,
|
||||||
|
Unique,
|
||||||
|
} from "@mikro-orm/core"
|
||||||
|
|
||||||
|
type OptionalFields = "deleted_at"
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
@Unique({
|
||||||
|
name: "IDX_workflow_execution_workflow_id_transaction_id_unique",
|
||||||
|
properties: ["workflow_id", "transaction_id"],
|
||||||
|
})
|
||||||
|
@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions)
|
||||||
|
export default class WorkflowExecution {
|
||||||
|
[OptionalProps]?: OptionalFields
|
||||||
|
|
||||||
|
@Property({ columnType: "text", nullable: false })
|
||||||
|
@Index({ name: "IDX_workflow_execution_id" })
|
||||||
|
id!: string
|
||||||
|
|
||||||
|
@Index({ name: "IDX_workflow_execution_workflow_id" })
|
||||||
|
@PrimaryKey({ columnType: "text" })
|
||||||
|
workflow_id: string
|
||||||
|
|
||||||
|
@Index({ name: "IDX_workflow_execution_transaction_id" })
|
||||||
|
@PrimaryKey({ columnType: "text" })
|
||||||
|
transaction_id: string
|
||||||
|
|
||||||
|
@Property({ columnType: "jsonb", nullable: true })
|
||||||
|
execution: Record<string, unknown> | null = null
|
||||||
|
|
||||||
|
@Property({ columnType: "jsonb", nullable: true })
|
||||||
|
context: Record<string, unknown> | null = null
|
||||||
|
|
||||||
|
@Index({ name: "IDX_workflow_execution_state" })
|
||||||
|
@Enum(() => TransactionState)
|
||||||
|
state: TransactionState
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
onCreate: () => new Date(),
|
||||||
|
columnType: "timestamptz",
|
||||||
|
defaultRaw: "now()",
|
||||||
|
})
|
||||||
|
created_at: Date
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
onCreate: () => new Date(),
|
||||||
|
onUpdate: () => new Date(),
|
||||||
|
columnType: "timestamptz",
|
||||||
|
defaultRaw: "now()",
|
||||||
|
})
|
||||||
|
updated_at: Date
|
||||||
|
|
||||||
|
@Property({ columnType: "timestamptz", nullable: true })
|
||||||
|
deleted_at: Date | null = null
|
||||||
|
|
||||||
|
@BeforeCreate()
|
||||||
|
onCreate() {
|
||||||
|
this.id = generateEntityId(this.id, "wf_exec")
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnInit()
|
||||||
|
onInit() {
|
||||||
|
this.id = generateEntityId(this.id, "wf_exec")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ModuleExports } from "@medusajs/types"
|
||||||
|
import { WorkflowsModuleService } from "@services"
|
||||||
|
import loadConnection from "./loaders/connection"
|
||||||
|
import loadContainer from "./loaders/container"
|
||||||
|
import loadUtils from "./loaders/utils"
|
||||||
|
|
||||||
|
const service = WorkflowsModuleService
|
||||||
|
const loaders = [loadContainer, loadConnection, loadUtils] as any
|
||||||
|
|
||||||
|
export const moduleDefinition: ModuleExports = {
|
||||||
|
service,
|
||||||
|
loaders,
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils"
|
||||||
|
export { WorkflowExecutionRepository } from "./workflow-execution"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { DALUtils } from "@medusajs/utils"
|
||||||
|
import { WorkflowExecution } from "@models"
|
||||||
|
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
export class WorkflowExecutionRepository extends DALUtils.mikroOrmBaseRepositoryFactory(
|
||||||
|
WorkflowExecution
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export default `
|
||||||
|
scalar DateTime
|
||||||
|
scalar JSON
|
||||||
|
|
||||||
|
enum TransactionState {
|
||||||
|
NOT_STARTED
|
||||||
|
INVOKING
|
||||||
|
WAITING_TO_COMPENSATE
|
||||||
|
COMPENSATING
|
||||||
|
DONE
|
||||||
|
REVERTED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowExecution {
|
||||||
|
id: ID!
|
||||||
|
created_at: DateTime!
|
||||||
|
updated_at: DateTime!
|
||||||
|
deleted_at: DateTime
|
||||||
|
workflow_id: string
|
||||||
|
transaction_id: string
|
||||||
|
execution: JSON
|
||||||
|
context: JSON
|
||||||
|
state: TransactionState
|
||||||
|
}
|
||||||
|
`
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
describe("Noop test", () => {
|
||||||
|
it("noop check", async () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./workflow-execution"
|
||||||
|
export * from "./workflow-orchestrator"
|
||||||
|
export * from "./workflows-module"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { DAL } from "@medusajs/types"
|
||||||
|
import { ModulesSdkUtils } from "@medusajs/utils"
|
||||||
|
import { WorkflowExecution } from "@models"
|
||||||
|
|
||||||
|
type InjectedDependencies = {
|
||||||
|
workflowExecutionRepository: DAL.RepositoryService
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkflowExecutionService<
|
||||||
|
TEntity extends WorkflowExecution = WorkflowExecution
|
||||||
|
> extends ModulesSdkUtils.abstractServiceFactory<InjectedDependencies, {}>(
|
||||||
|
WorkflowExecution
|
||||||
|
)<TEntity> {
|
||||||
|
protected workflowExecutionRepository_: DAL.RepositoryService<TEntity>
|
||||||
|
|
||||||
|
constructor({ workflowExecutionRepository }: InjectedDependencies) {
|
||||||
|
// @ts-ignore
|
||||||
|
super(...arguments)
|
||||||
|
this.workflowExecutionRepository_ = workflowExecutionRepository
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
import {
|
||||||
|
DistributedTransaction,
|
||||||
|
DistributedTransactionEvents,
|
||||||
|
TransactionHandlerType,
|
||||||
|
TransactionStep,
|
||||||
|
} from "@medusajs/orchestration"
|
||||||
|
import { ContainerLike, Context, MedusaContainer } from "@medusajs/types"
|
||||||
|
import { InjectSharedContext, isString, MedusaContext } from "@medusajs/utils"
|
||||||
|
import {
|
||||||
|
type FlowRunOptions,
|
||||||
|
MedusaWorkflow,
|
||||||
|
ReturnWorkflow,
|
||||||
|
} from "@medusajs/workflows-sdk"
|
||||||
|
import { ulid } from "ulid"
|
||||||
|
import { InMemoryDistributedTransactionStorage } from "../utils"
|
||||||
|
|
||||||
|
export type WorkflowOrchestratorRunOptions<T> = FlowRunOptions<T> & {
|
||||||
|
transactionId?: string
|
||||||
|
container?: ContainerLike
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterStepSuccessOptions<T> = Omit<
|
||||||
|
WorkflowOrchestratorRunOptions<T>,
|
||||||
|
"transactionId" | "input"
|
||||||
|
>
|
||||||
|
|
||||||
|
type IdempotencyKeyParts = {
|
||||||
|
workflowId: string
|
||||||
|
transactionId: string
|
||||||
|
stepId: string
|
||||||
|
action: "invoke" | "compensate"
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotifyOptions = {
|
||||||
|
eventType: keyof DistributedTransactionEvents
|
||||||
|
workflowId: string
|
||||||
|
transactionId?: string
|
||||||
|
step?: TransactionStep
|
||||||
|
response?: unknown
|
||||||
|
result?: unknown
|
||||||
|
errors?: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowId = string
|
||||||
|
type TransactionId = string
|
||||||
|
|
||||||
|
type SubscriberHandler = {
|
||||||
|
(input: NotifyOptions): void
|
||||||
|
} & {
|
||||||
|
_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscribeOptions = {
|
||||||
|
workflowId: string
|
||||||
|
transactionId?: string
|
||||||
|
subscriber: SubscriberHandler
|
||||||
|
subscriberId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnsubscribeOptions = {
|
||||||
|
workflowId: string
|
||||||
|
transactionId?: string
|
||||||
|
subscriberOrId: string | SubscriberHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransactionSubscribers = Map<TransactionId, SubscriberHandler[]>
|
||||||
|
type Subscribers = Map<WorkflowId, TransactionSubscribers>
|
||||||
|
|
||||||
|
const AnySubscriber = "any"
|
||||||
|
|
||||||
|
export class WorkflowOrchestratorService {
|
||||||
|
private subscribers: Subscribers = new Map()
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
inMemoryDistributedTransactionStorage,
|
||||||
|
}: {
|
||||||
|
inMemoryDistributedTransactionStorage: InMemoryDistributedTransactionStorage
|
||||||
|
workflowOrchestratorService: WorkflowOrchestratorService
|
||||||
|
}) {
|
||||||
|
inMemoryDistributedTransactionStorage.setWorkflowOrchestratorService(this)
|
||||||
|
DistributedTransaction.setStorage(inMemoryDistributedTransactionStorage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectSharedContext()
|
||||||
|
async run<T = unknown>(
|
||||||
|
workflowIdOrWorkflow: string | ReturnWorkflow<any, any, any>,
|
||||||
|
options?: WorkflowOrchestratorRunOptions<T>,
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
) {
|
||||||
|
let {
|
||||||
|
input,
|
||||||
|
context,
|
||||||
|
transactionId,
|
||||||
|
resultFrom,
|
||||||
|
throwOnError,
|
||||||
|
events: eventHandlers,
|
||||||
|
container,
|
||||||
|
} = options ?? {}
|
||||||
|
|
||||||
|
const workflowId = isString(workflowIdOrWorkflow)
|
||||||
|
? workflowIdOrWorkflow
|
||||||
|
: workflowIdOrWorkflow.getName()
|
||||||
|
|
||||||
|
if (!workflowId) {
|
||||||
|
throw new Error("Workflow ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
context ??= {}
|
||||||
|
context.transactionId ??= transactionId ?? ulid()
|
||||||
|
|
||||||
|
const events: FlowRunOptions["events"] = this.buildWorkflowEvents({
|
||||||
|
customEventHandlers: eventHandlers,
|
||||||
|
workflowId,
|
||||||
|
transactionId: context.transactionId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const exportedWorkflow: any = MedusaWorkflow.getWorkflow(workflowId)
|
||||||
|
if (!exportedWorkflow) {
|
||||||
|
throw new Error(`Workflow with id "${workflowId}" not found.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = exportedWorkflow(container as MedusaContainer)
|
||||||
|
|
||||||
|
const ret = await flow.run({
|
||||||
|
input,
|
||||||
|
throwOnError,
|
||||||
|
resultFrom,
|
||||||
|
context,
|
||||||
|
events,
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: temporary
|
||||||
|
const acknowledgement = {
|
||||||
|
transactionId: context.transactionId,
|
||||||
|
workflowId: workflowId,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ret.transaction.hasFinished()) {
|
||||||
|
const { result, errors } = ret
|
||||||
|
this.notify({
|
||||||
|
eventType: "onFinish",
|
||||||
|
workflowId,
|
||||||
|
transactionId: context.transactionId,
|
||||||
|
result,
|
||||||
|
errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { acknowledgement, ...ret }
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectSharedContext()
|
||||||
|
async getRunningTransaction(
|
||||||
|
workflowId: string,
|
||||||
|
transactionId: string,
|
||||||
|
options?: WorkflowOrchestratorRunOptions<undefined>,
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
): Promise<DistributedTransaction> {
|
||||||
|
let { context, container } = options ?? {}
|
||||||
|
|
||||||
|
if (!workflowId) {
|
||||||
|
throw new Error("Workflow ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transactionId) {
|
||||||
|
throw new Error("TransactionId ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
context ??= {}
|
||||||
|
context.transactionId ??= transactionId
|
||||||
|
|
||||||
|
const exportedWorkflow: any = MedusaWorkflow.getWorkflow(workflowId)
|
||||||
|
if (!exportedWorkflow) {
|
||||||
|
throw new Error(`Workflow with id "${workflowId}" not found.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = exportedWorkflow(container as MedusaContainer)
|
||||||
|
|
||||||
|
const transaction = await flow.getRunningTransaction(transactionId, context)
|
||||||
|
|
||||||
|
return transaction
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectSharedContext()
|
||||||
|
async setStepSuccess<T = unknown>(
|
||||||
|
{
|
||||||
|
idempotencyKey,
|
||||||
|
stepResponse,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
idempotencyKey: string | IdempotencyKeyParts
|
||||||
|
stepResponse: unknown
|
||||||
|
options?: RegisterStepSuccessOptions<T>
|
||||||
|
},
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
context,
|
||||||
|
throwOnError,
|
||||||
|
resultFrom,
|
||||||
|
container,
|
||||||
|
events: eventHandlers,
|
||||||
|
} = options ?? {}
|
||||||
|
|
||||||
|
const [idempotencyKey_, { workflowId, transactionId }] =
|
||||||
|
this.buildIdempotencyKeyAndParts(idempotencyKey)
|
||||||
|
|
||||||
|
const exportedWorkflow: any = MedusaWorkflow.getWorkflow(workflowId)
|
||||||
|
if (!exportedWorkflow) {
|
||||||
|
throw new Error(`Workflow with id "${workflowId}" not found.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = exportedWorkflow(container as MedusaContainer)
|
||||||
|
|
||||||
|
const events = this.buildWorkflowEvents({
|
||||||
|
customEventHandlers: eventHandlers,
|
||||||
|
transactionId,
|
||||||
|
workflowId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ret = await flow.registerStepSuccess({
|
||||||
|
idempotencyKey: idempotencyKey_,
|
||||||
|
context,
|
||||||
|
resultFrom,
|
||||||
|
throwOnError,
|
||||||
|
events,
|
||||||
|
response: stepResponse,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ret.transaction.hasFinished()) {
|
||||||
|
const { result, errors } = ret
|
||||||
|
this.notify({
|
||||||
|
eventType: "onFinish",
|
||||||
|
workflowId,
|
||||||
|
transactionId,
|
||||||
|
result,
|
||||||
|
errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectSharedContext()
|
||||||
|
async setStepFailure<T = unknown>(
|
||||||
|
{
|
||||||
|
idempotencyKey,
|
||||||
|
stepResponse,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
idempotencyKey: string | IdempotencyKeyParts
|
||||||
|
stepResponse: unknown
|
||||||
|
options?: RegisterStepSuccessOptions<T>
|
||||||
|
},
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
context,
|
||||||
|
throwOnError,
|
||||||
|
resultFrom,
|
||||||
|
container,
|
||||||
|
events: eventHandlers,
|
||||||
|
} = options ?? {}
|
||||||
|
|
||||||
|
const [idempotencyKey_, { workflowId, transactionId }] =
|
||||||
|
this.buildIdempotencyKeyAndParts(idempotencyKey)
|
||||||
|
|
||||||
|
const exportedWorkflow: any = MedusaWorkflow.getWorkflow(workflowId)
|
||||||
|
if (!exportedWorkflow) {
|
||||||
|
throw new Error(`Workflow with id "${workflowId}" not found.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = exportedWorkflow(container as MedusaContainer)
|
||||||
|
|
||||||
|
const events = this.buildWorkflowEvents({
|
||||||
|
customEventHandlers: eventHandlers,
|
||||||
|
transactionId,
|
||||||
|
workflowId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ret = await flow.registerStepFailure({
|
||||||
|
idempotencyKey: idempotencyKey_,
|
||||||
|
context,
|
||||||
|
resultFrom,
|
||||||
|
throwOnError,
|
||||||
|
events,
|
||||||
|
response: stepResponse,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ret.transaction.hasFinished()) {
|
||||||
|
const { result, errors } = ret
|
||||||
|
this.notify({
|
||||||
|
eventType: "onFinish",
|
||||||
|
workflowId,
|
||||||
|
transactionId,
|
||||||
|
result,
|
||||||
|
errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectSharedContext()
|
||||||
|
subscribe(
|
||||||
|
{ workflowId, transactionId, subscriber, subscriberId }: SubscribeOptions,
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
) {
|
||||||
|
subscriber._id = subscriberId
|
||||||
|
const subscribers = this.subscribers.get(workflowId) ?? new Map()
|
||||||
|
|
||||||
|
const handlerIndex = (handlers) => {
|
||||||
|
return handlers.indexOf((s) => s === subscriber || s._id === subscriberId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactionId) {
|
||||||
|
const transactionSubscribers = subscribers.get(transactionId) ?? []
|
||||||
|
const subscriberIndex = handlerIndex(transactionSubscribers)
|
||||||
|
if (subscriberIndex !== -1) {
|
||||||
|
transactionSubscribers.slice(subscriberIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionSubscribers.push(subscriber)
|
||||||
|
subscribers.set(transactionId, transactionSubscribers)
|
||||||
|
this.subscribers.set(workflowId, subscribers)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowSubscribers = subscribers.get(AnySubscriber) ?? []
|
||||||
|
const subscriberIndex = handlerIndex(workflowSubscribers)
|
||||||
|
if (subscriberIndex !== -1) {
|
||||||
|
workflowSubscribers.slice(subscriberIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowSubscribers.push(subscriber)
|
||||||
|
subscribers.set(AnySubscriber, workflowSubscribers)
|
||||||
|
this.subscribers.set(workflowId, subscribers)
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectSharedContext()
|
||||||
|
unsubscribe(
|
||||||
|
{ workflowId, transactionId, subscriberOrId }: UnsubscribeOptions,
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
) {
|
||||||
|
const subscribers = this.subscribers.get(workflowId) ?? new Map()
|
||||||
|
|
||||||
|
const filterSubscribers = (handlers: SubscriberHandler[]) => {
|
||||||
|
return handlers.filter((handler) => {
|
||||||
|
return handler._id
|
||||||
|
? handler._id !== (subscriberOrId as string)
|
||||||
|
: handler !== (subscriberOrId as SubscriberHandler)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactionId) {
|
||||||
|
const transactionSubscribers = subscribers.get(transactionId) ?? []
|
||||||
|
const newTransactionSubscribers = filterSubscribers(
|
||||||
|
transactionSubscribers
|
||||||
|
)
|
||||||
|
subscribers.set(transactionId, newTransactionSubscribers)
|
||||||
|
this.subscribers.set(workflowId, subscribers)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowSubscribers = subscribers.get(AnySubscriber) ?? []
|
||||||
|
const newWorkflowSubscribers = filterSubscribers(workflowSubscribers)
|
||||||
|
subscribers.set(AnySubscriber, newWorkflowSubscribers)
|
||||||
|
this.subscribers.set(workflowId, subscribers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(options: NotifyOptions) {
|
||||||
|
const {
|
||||||
|
eventType,
|
||||||
|
workflowId,
|
||||||
|
transactionId,
|
||||||
|
errors,
|
||||||
|
result,
|
||||||
|
step,
|
||||||
|
response,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const subscribers: TransactionSubscribers =
|
||||||
|
this.subscribers.get(workflowId) ?? new Map()
|
||||||
|
|
||||||
|
const notifySubscribers = (handlers: SubscriberHandler[]) => {
|
||||||
|
handlers.forEach((handler) => {
|
||||||
|
handler({
|
||||||
|
eventType,
|
||||||
|
workflowId,
|
||||||
|
transactionId,
|
||||||
|
step,
|
||||||
|
response,
|
||||||
|
result,
|
||||||
|
errors,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactionId) {
|
||||||
|
const transactionSubscribers = subscribers.get(transactionId) ?? []
|
||||||
|
notifySubscribers(transactionSubscribers)
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowSubscribers = subscribers.get(AnySubscriber) ?? []
|
||||||
|
notifySubscribers(workflowSubscribers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWorkflowEvents({
|
||||||
|
customEventHandlers,
|
||||||
|
workflowId,
|
||||||
|
transactionId,
|
||||||
|
}): DistributedTransactionEvents {
|
||||||
|
const notify = ({
|
||||||
|
eventType,
|
||||||
|
step,
|
||||||
|
result,
|
||||||
|
response,
|
||||||
|
errors,
|
||||||
|
}: {
|
||||||
|
eventType: keyof DistributedTransactionEvents
|
||||||
|
step?: TransactionStep
|
||||||
|
response?: unknown
|
||||||
|
result?: unknown
|
||||||
|
errors?: unknown[]
|
||||||
|
}) => {
|
||||||
|
this.notify({
|
||||||
|
workflowId,
|
||||||
|
transactionId,
|
||||||
|
eventType,
|
||||||
|
response,
|
||||||
|
step,
|
||||||
|
result,
|
||||||
|
errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onTimeout: ({ transaction }) => {
|
||||||
|
customEventHandlers?.onTimeout?.({ transaction })
|
||||||
|
notify({ eventType: "onTimeout" })
|
||||||
|
},
|
||||||
|
|
||||||
|
onBegin: ({ transaction }) => {
|
||||||
|
customEventHandlers?.onBegin?.({ transaction })
|
||||||
|
notify({ eventType: "onBegin" })
|
||||||
|
},
|
||||||
|
onResume: ({ transaction }) => {
|
||||||
|
customEventHandlers?.onResume?.({ transaction })
|
||||||
|
notify({ eventType: "onResume" })
|
||||||
|
},
|
||||||
|
onCompensateBegin: ({ transaction }) => {
|
||||||
|
customEventHandlers?.onCompensateBegin?.({ transaction })
|
||||||
|
notify({ eventType: "onCompensateBegin" })
|
||||||
|
},
|
||||||
|
onFinish: ({ transaction, result, errors }) => {
|
||||||
|
// TODO: unsubscribe transaction handlers on finish
|
||||||
|
customEventHandlers?.onFinish?.({ transaction, result, errors })
|
||||||
|
},
|
||||||
|
|
||||||
|
onStepBegin: ({ step, transaction }) => {
|
||||||
|
customEventHandlers?.onStepBegin?.({ step, transaction })
|
||||||
|
|
||||||
|
notify({ eventType: "onStepBegin", step })
|
||||||
|
},
|
||||||
|
onStepSuccess: ({ step, transaction }) => {
|
||||||
|
const response = transaction.getContext().invoke[step.id]
|
||||||
|
customEventHandlers?.onStepSuccess?.({ step, transaction, response })
|
||||||
|
|
||||||
|
notify({ eventType: "onStepSuccess", step, response })
|
||||||
|
},
|
||||||
|
onStepFailure: ({ step, transaction }) => {
|
||||||
|
const errors = transaction.getErrors(TransactionHandlerType.INVOKE)[
|
||||||
|
step.id
|
||||||
|
]
|
||||||
|
customEventHandlers?.onStepFailure?.({ step, transaction, errors })
|
||||||
|
|
||||||
|
notify({ eventType: "onStepFailure", step, errors })
|
||||||
|
},
|
||||||
|
|
||||||
|
onCompensateStepSuccess: ({ step, transaction }) => {
|
||||||
|
const response = transaction.getContext().compensate[step.id]
|
||||||
|
customEventHandlers?.onStepSuccess?.({ step, transaction, response })
|
||||||
|
|
||||||
|
notify({ eventType: "onCompensateStepSuccess", step, response })
|
||||||
|
},
|
||||||
|
onCompensateStepFailure: ({ step, transaction }) => {
|
||||||
|
const errors = transaction.getErrors(TransactionHandlerType.COMPENSATE)[
|
||||||
|
step.id
|
||||||
|
]
|
||||||
|
customEventHandlers?.onStepFailure?.({ step, transaction, errors })
|
||||||
|
|
||||||
|
notify({ eventType: "onCompensateStepFailure", step, errors })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildIdempotencyKeyAndParts(
|
||||||
|
idempotencyKey: string | IdempotencyKeyParts
|
||||||
|
): [string, IdempotencyKeyParts] {
|
||||||
|
const parts: IdempotencyKeyParts = {
|
||||||
|
workflowId: "",
|
||||||
|
transactionId: "",
|
||||||
|
stepId: "",
|
||||||
|
action: "invoke",
|
||||||
|
}
|
||||||
|
let idempotencyKey_ = idempotencyKey as string
|
||||||
|
|
||||||
|
const setParts = (workflowId, transactionId, stepId, action) => {
|
||||||
|
parts.workflowId = workflowId
|
||||||
|
parts.transactionId = transactionId
|
||||||
|
parts.stepId = stepId
|
||||||
|
parts.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isString(idempotencyKey)) {
|
||||||
|
const { workflowId, transactionId, stepId, action } =
|
||||||
|
idempotencyKey as IdempotencyKeyParts
|
||||||
|
idempotencyKey_ = [workflowId, transactionId, stepId, action].join(":")
|
||||||
|
setParts(workflowId, transactionId, stepId, action)
|
||||||
|
} else {
|
||||||
|
const [workflowId, transactionId, stepId, action] =
|
||||||
|
idempotencyKey_.split(":")
|
||||||
|
setParts(workflowId, transactionId, stepId, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [idempotencyKey_, parts]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import {
|
||||||
|
Context,
|
||||||
|
DAL,
|
||||||
|
FindConfig,
|
||||||
|
InternalModuleDeclaration,
|
||||||
|
ModuleJoinerConfig,
|
||||||
|
} from "@medusajs/types"
|
||||||
|
import {} from "@medusajs/types/src"
|
||||||
|
import {
|
||||||
|
InjectManager,
|
||||||
|
InjectSharedContext,
|
||||||
|
MedusaContext,
|
||||||
|
} from "@medusajs/utils"
|
||||||
|
import type {
|
||||||
|
ReturnWorkflow,
|
||||||
|
UnwrapWorkflowInputDataType,
|
||||||
|
WorkflowOrchestratorTypes,
|
||||||
|
} from "@medusajs/workflows-sdk"
|
||||||
|
import {
|
||||||
|
WorkflowExecutionService,
|
||||||
|
WorkflowOrchestratorService,
|
||||||
|
} from "@services"
|
||||||
|
import { joinerConfig } from "../joiner-config"
|
||||||
|
|
||||||
|
type InjectedDependencies = {
|
||||||
|
baseRepository: DAL.RepositoryService
|
||||||
|
workflowExecutionService: WorkflowExecutionService
|
||||||
|
workflowOrchestratorService: WorkflowOrchestratorService
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkflowsModuleService
|
||||||
|
implements WorkflowOrchestratorTypes.IWorkflowsModuleService
|
||||||
|
{
|
||||||
|
protected baseRepository_: DAL.RepositoryService
|
||||||
|
protected workflowExecutionService_: WorkflowExecutionService
|
||||||
|
protected workflowOrchestratorService_: WorkflowOrchestratorService
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
{
|
||||||
|
baseRepository,
|
||||||
|
workflowExecutionService,
|
||||||
|
workflowOrchestratorService,
|
||||||
|
}: InjectedDependencies,
|
||||||
|
protected readonly moduleDeclaration: InternalModuleDeclaration
|
||||||
|
) {
|
||||||
|
this.baseRepository_ = baseRepository
|
||||||
|
this.workflowExecutionService_ = workflowExecutionService
|
||||||
|
this.workflowOrchestratorService_ = workflowOrchestratorService
|
||||||
|
}
|
||||||
|
|
||||||
|
__joinerConfig(): ModuleJoinerConfig {
|
||||||
|
return joinerConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectManager("baseRepository_")
|
||||||
|
async listWorkflowExecution(
|
||||||
|
filters: WorkflowOrchestratorTypes.FilterableWorkflowExecutionProps = {},
|
||||||
|
config: FindConfig<WorkflowOrchestratorTypes.WorkflowExecutionDTO> = {},
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
): Promise<WorkflowOrchestratorTypes.WorkflowExecutionDTO[]> {
|
||||||
|
const wfExecutions = await this.workflowExecutionService_.list(
|
||||||
|
filters,
|
||||||
|
config,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.baseRepository_.serialize<
|
||||||
|
WorkflowOrchestratorTypes.WorkflowExecutionDTO[]
|
||||||
|
>(wfExecutions, {
|
||||||
|
populate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectManager("baseRepository_")
|
||||||
|
async listAndCountWorkflowExecution(
|
||||||
|
filters: WorkflowOrchestratorTypes.FilterableWorkflowExecutionProps = {},
|
||||||
|
config: FindConfig<WorkflowOrchestratorTypes.WorkflowExecutionDTO> = {},
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
): Promise<[WorkflowOrchestratorTypes.WorkflowExecutionDTO[], number]> {
|
||||||
|
const [wfExecutions, count] =
|
||||||
|
await this.workflowExecutionService_.listAndCount(
|
||||||
|
filters,
|
||||||
|
config,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
await this.baseRepository_.serialize<
|
||||||
|
WorkflowOrchestratorTypes.WorkflowExecutionDTO[]
|
||||||
|
>(wfExecutions, {
|
||||||
|
populate: true,
|
||||||
|
}),
|
||||||
|
count,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectSharedContext()
|
||||||
|
async run<TWorkflow extends string | ReturnWorkflow<any, any, any>>(
|
||||||
|
workflowIdOrWorkflow: TWorkflow,
|
||||||
|
options: WorkflowOrchestratorTypes.WorkflowOrchestratorRunDTO<
|
||||||
|
TWorkflow extends ReturnWorkflow<any, any, any>
|
||||||
|
? UnwrapWorkflowInputDataType<TWorkflow>
|
||||||
|
: unknown
|
||||||
|
> = {},
|
||||||
|
@MedusaContext() context: Context = {}
|
||||||
|
) {
|
||||||
|
const ret = await this.workflowOrchestratorService_.run<
|
||||||
|
TWorkflow extends ReturnWorkflow<any, any, any>
|
||||||
|
? UnwrapWorkflowInputDataType<TWorkflow>
|
||||||
|
: unknown
|
||||||
|
>(workflowIdOrWorkflow, options, context)
|
||||||
|
|
||||||
|
return ret as any
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectSharedContext()
|
||||||
|
async getRunningTransaction(
|
||||||
|
workflowId: string,
|
||||||
|
transactionId: string,
|
||||||
|
@MedusaContext() context: Context = {}
|
||||||
|
) {
|
||||||
|
return this.workflowOrchestratorService_.getRunningTransaction(
|
||||||
|
workflowId,
|
||||||
|
transactionId,
|
||||||
|
context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectSharedContext()
|
||||||
|
async setStepSuccess(
|
||||||
|
{
|
||||||
|
idempotencyKey,
|
||||||
|
stepResponse,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
idempotencyKey: string | object
|
||||||
|
stepResponse: unknown
|
||||||
|
options?: Record<string, any>
|
||||||
|
},
|
||||||
|
@MedusaContext() context: Context = {}
|
||||||
|
) {
|
||||||
|
return this.workflowOrchestratorService_.setStepSuccess(
|
||||||
|
{
|
||||||
|
idempotencyKey,
|
||||||
|
stepResponse,
|
||||||
|
options,
|
||||||
|
} as any,
|
||||||
|
context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectSharedContext()
|
||||||
|
async setStepFailure(
|
||||||
|
{
|
||||||
|
idempotencyKey,
|
||||||
|
stepResponse,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
idempotencyKey: string | object
|
||||||
|
stepResponse: unknown
|
||||||
|
options?: Record<string, any>
|
||||||
|
},
|
||||||
|
@MedusaContext() context: Context = {}
|
||||||
|
) {
|
||||||
|
return this.workflowOrchestratorService_.setStepFailure(
|
||||||
|
{
|
||||||
|
idempotencyKey,
|
||||||
|
stepResponse,
|
||||||
|
options,
|
||||||
|
} as any,
|
||||||
|
context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectSharedContext()
|
||||||
|
async subscribe(
|
||||||
|
args: {
|
||||||
|
workflowId: string
|
||||||
|
transactionId?: string
|
||||||
|
subscriber: Function
|
||||||
|
subscriberId?: string
|
||||||
|
},
|
||||||
|
@MedusaContext() context: Context = {}
|
||||||
|
) {
|
||||||
|
return this.workflowOrchestratorService_.subscribe(args as any, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectSharedContext()
|
||||||
|
async unsubscribe(
|
||||||
|
args: {
|
||||||
|
workflowId: string
|
||||||
|
transactionId?: string
|
||||||
|
subscriberOrId: string | Function
|
||||||
|
},
|
||||||
|
@MedusaContext() context: Context = {}
|
||||||
|
) {
|
||||||
|
return this.workflowOrchestratorService_.unsubscribe(args as any, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Logger } from "@medusajs/types"
|
||||||
|
|
||||||
|
export type InitializeModuleInjectableDependencies = {
|
||||||
|
logger?: Logger
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./workflow-orchestrator-storage"
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import {
|
||||||
|
DistributedTransaction,
|
||||||
|
DistributedTransactionStorage,
|
||||||
|
TransactionCheckpoint,
|
||||||
|
TransactionStep,
|
||||||
|
} from "@medusajs/orchestration"
|
||||||
|
import { TransactionState } from "@medusajs/utils"
|
||||||
|
import {
|
||||||
|
WorkflowExecutionService,
|
||||||
|
WorkflowOrchestratorService,
|
||||||
|
} from "@services"
|
||||||
|
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
export class InMemoryDistributedTransactionStorage extends DistributedTransactionStorage {
|
||||||
|
private workflowExecutionService_: WorkflowExecutionService
|
||||||
|
private workflowOrchestratorService_: WorkflowOrchestratorService
|
||||||
|
|
||||||
|
private storage: Map<string, TransactionCheckpoint> = new Map()
|
||||||
|
private retries: Map<string, unknown> = new Map()
|
||||||
|
private timeouts: Map<string, unknown> = new Map()
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
workflowExecutionService,
|
||||||
|
}: {
|
||||||
|
workflowExecutionService: WorkflowExecutionService
|
||||||
|
}) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.workflowExecutionService_ = workflowExecutionService
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorkflowOrchestratorService(workflowOrchestratorService) {
|
||||||
|
this.workflowOrchestratorService_ = workflowOrchestratorService
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveToDb(data: TransactionCheckpoint) {
|
||||||
|
await this.workflowExecutionService_.upsert([
|
||||||
|
{
|
||||||
|
workflow_id: data.flow.modelId,
|
||||||
|
transaction_id: data.flow.transactionId,
|
||||||
|
execution: data.flow,
|
||||||
|
context: {
|
||||||
|
data: data.context,
|
||||||
|
errors: data.errors,
|
||||||
|
},
|
||||||
|
state: data.flow.state,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteFromDb(data: TransactionCheckpoint) {
|
||||||
|
await this.workflowExecutionService_.delete([
|
||||||
|
{
|
||||||
|
workflow_id: data.flow.modelId,
|
||||||
|
transaction_id: data.flow.transactionId,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<TransactionCheckpoint | undefined> {
|
||||||
|
return this.storage.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<TransactionCheckpoint[]> {
|
||||||
|
return Array.from(this.storage.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(
|
||||||
|
key: string,
|
||||||
|
data: TransactionCheckpoint,
|
||||||
|
ttl?: number
|
||||||
|
): Promise<void> {
|
||||||
|
this.storage.set(key, data)
|
||||||
|
|
||||||
|
let retentionTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the retention time only if the transaction is done, failed or reverted.
|
||||||
|
* From that moment, this tuple can be later on archived or deleted after the retention time.
|
||||||
|
*/
|
||||||
|
const hasFinished = [
|
||||||
|
TransactionState.DONE,
|
||||||
|
TransactionState.FAILED,
|
||||||
|
TransactionState.REVERTED,
|
||||||
|
].includes(data.flow.state)
|
||||||
|
|
||||||
|
if (hasFinished) {
|
||||||
|
retentionTime = data.flow.options?.retentionTime
|
||||||
|
Object.assign(data, {
|
||||||
|
retention_time: retentionTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFinished && !retentionTime) {
|
||||||
|
await this.deleteFromDb(data)
|
||||||
|
} else {
|
||||||
|
await this.saveToDb(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFinished) {
|
||||||
|
this.storage.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async scheduleRetry(
|
||||||
|
transaction: DistributedTransaction,
|
||||||
|
step: TransactionStep,
|
||||||
|
timestamp: number,
|
||||||
|
interval: number
|
||||||
|
): Promise<void> {
|
||||||
|
const { modelId: workflowId, transactionId } = transaction
|
||||||
|
|
||||||
|
const inter = setTimeout(async () => {
|
||||||
|
await this.workflowOrchestratorService_.run(workflowId, {
|
||||||
|
transactionId,
|
||||||
|
throwOnError: false,
|
||||||
|
})
|
||||||
|
}, interval * 1e3)
|
||||||
|
|
||||||
|
const key = `${workflowId}:${transactionId}:${step.id}`
|
||||||
|
this.retries.set(key, inter)
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearRetry(
|
||||||
|
transaction: DistributedTransaction,
|
||||||
|
step: TransactionStep
|
||||||
|
): Promise<void> {
|
||||||
|
const { modelId: workflowId, transactionId } = transaction
|
||||||
|
|
||||||
|
const key = `${workflowId}:${transactionId}:${step.id}`
|
||||||
|
const inter = this.retries.get(key)
|
||||||
|
if (inter) {
|
||||||
|
clearTimeout(inter as NodeJS.Timeout)
|
||||||
|
this.retries.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async scheduleTransactionTimeout(
|
||||||
|
transaction: DistributedTransaction,
|
||||||
|
timestamp: number,
|
||||||
|
interval: number
|
||||||
|
): Promise<void> {
|
||||||
|
const { modelId: workflowId, transactionId } = transaction
|
||||||
|
|
||||||
|
const inter = setTimeout(async () => {
|
||||||
|
await this.workflowOrchestratorService_.run(workflowId, {
|
||||||
|
transactionId,
|
||||||
|
throwOnError: false,
|
||||||
|
})
|
||||||
|
}, interval * 1e3)
|
||||||
|
|
||||||
|
const key = `${workflowId}:${transactionId}`
|
||||||
|
this.timeouts.set(key, inter)
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearTransactionTimeout(
|
||||||
|
transaction: DistributedTransaction
|
||||||
|
): Promise<void> {
|
||||||
|
const { modelId: workflowId, transactionId } = transaction
|
||||||
|
|
||||||
|
const key = `${workflowId}:${transactionId}`
|
||||||
|
const inter = this.timeouts.get(key)
|
||||||
|
if (inter) {
|
||||||
|
clearTimeout(inter as NodeJS.Timeout)
|
||||||
|
this.timeouts.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async scheduleStepTimeout(
|
||||||
|
transaction: DistributedTransaction,
|
||||||
|
step: TransactionStep,
|
||||||
|
timestamp: number,
|
||||||
|
interval: number
|
||||||
|
): Promise<void> {
|
||||||
|
const { modelId: workflowId, transactionId } = transaction
|
||||||
|
|
||||||
|
const inter = setTimeout(async () => {
|
||||||
|
await this.workflowOrchestratorService_.run(workflowId, {
|
||||||
|
transactionId,
|
||||||
|
throwOnError: false,
|
||||||
|
})
|
||||||
|
}, interval * 1e3)
|
||||||
|
|
||||||
|
const key = `${workflowId}:${transactionId}:${step.id}`
|
||||||
|
this.timeouts.set(key, inter)
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearStepTimeout(
|
||||||
|
transaction: DistributedTransaction,
|
||||||
|
step: TransactionStep
|
||||||
|
): Promise<void> {
|
||||||
|
const { modelId: workflowId, transactionId } = transaction
|
||||||
|
|
||||||
|
const key = `${workflowId}:${transactionId}:${step.id}`
|
||||||
|
const inter = this.timeouts.get(key)
|
||||||
|
if (inter) {
|
||||||
|
clearTimeout(inter as NodeJS.Timeout)
|
||||||
|
this.timeouts.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["es2020"],
|
||||||
|
"target": "es2020",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"sourceMap": false,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"downlevelIteration": true, // to use ES5 specific tooling
|
||||||
|
"baseUrl": ".",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"paths": {
|
||||||
|
"@models": ["./src/models"],
|
||||||
|
"@services": ["./src/services"],
|
||||||
|
"@repositories": ["./src/repositories"],
|
||||||
|
"@types": ["./src/types"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": [
|
||||||
|
"dist",
|
||||||
|
"./src/**/__tests__",
|
||||||
|
"./src/**/__mocks__",
|
||||||
|
"./src/**/__fixtures__",
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"include": ["src", "integration-tests"],
|
||||||
|
"exclude": ["node_modules", "dist"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/dist
|
||||||
|
node_modules
|
||||||
|
.DS_store
|
||||||
|
.env*
|
||||||
|
.env
|
||||||
|
*.sql
|
||||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
# Workflow Orchestrator
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./workflow_1"
|
||||||
|
export * from "./workflow_2"
|
||||||
|
export * from "./workflow_step_timeout"
|
||||||
|
export * from "./workflow_transaction_timeout"
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
StepResponse,
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
} from "@medusajs/workflows-sdk"
|
||||||
|
|
||||||
|
const step_1 = createStep(
|
||||||
|
"step_1",
|
||||||
|
jest.fn((input) => {
|
||||||
|
input.test = "test"
|
||||||
|
return new StepResponse(input, { compensate: 123 })
|
||||||
|
}),
|
||||||
|
jest.fn((compensateInput) => {
|
||||||
|
if (!compensateInput) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("reverted", compensateInput.compensate)
|
||||||
|
return new StepResponse({
|
||||||
|
reverted: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const step_2 = createStep(
|
||||||
|
"step_2",
|
||||||
|
jest.fn((input, context) => {
|
||||||
|
console.log("triggered async request", context.metadata.idempotency_key)
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
return new StepResponse({ notAsyncResponse: input.hey })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
jest.fn((_, context) => {
|
||||||
|
return new StepResponse({
|
||||||
|
step: context.metadata.action,
|
||||||
|
idempotency_key: context.metadata.idempotency_key,
|
||||||
|
reverted: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const step_3 = createStep(
|
||||||
|
"step_3",
|
||||||
|
jest.fn((res) => {
|
||||||
|
return new StepResponse({
|
||||||
|
done: {
|
||||||
|
inputFromSyncStep: res.notAsyncResponse,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
createWorkflow("workflow_1", function (input) {
|
||||||
|
step_1(input)
|
||||||
|
|
||||||
|
const ret2 = step_2({ hey: "oh" })
|
||||||
|
|
||||||
|
step_2({ hey: "async hello" }).config({
|
||||||
|
name: "new_step_name",
|
||||||
|
async: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return step_3(ret2)
|
||||||
|
})
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
StepResponse,
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
} from "@medusajs/workflows-sdk"
|
||||||
|
|
||||||
|
const step_1 = createStep(
|
||||||
|
"step_1",
|
||||||
|
jest.fn((input) => {
|
||||||
|
input.test = "test"
|
||||||
|
return new StepResponse(input, { compensate: 123 })
|
||||||
|
}),
|
||||||
|
jest.fn((compensateInput) => {
|
||||||
|
if (!compensateInput) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("reverted", compensateInput.compensate)
|
||||||
|
return new StepResponse({
|
||||||
|
reverted: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const step_2 = createStep(
|
||||||
|
"step_2",
|
||||||
|
jest.fn((input, context) => {
|
||||||
|
console.log("triggered async request", context.metadata.idempotency_key)
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
return new StepResponse({ notAsyncResponse: input.hey })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
jest.fn((_, context) => {
|
||||||
|
return new StepResponse({
|
||||||
|
step: context.metadata.action,
|
||||||
|
idempotency_key: context.metadata.idempotency_key,
|
||||||
|
reverted: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const step_3 = createStep(
|
||||||
|
"step_3",
|
||||||
|
jest.fn((res) => {
|
||||||
|
return new StepResponse({
|
||||||
|
done: {
|
||||||
|
inputFromSyncStep: res.notAsyncResponse,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
createWorkflow(
|
||||||
|
{
|
||||||
|
name: "workflow_2",
|
||||||
|
retentionTime: 1000,
|
||||||
|
},
|
||||||
|
function (input) {
|
||||||
|
step_1(input)
|
||||||
|
|
||||||
|
const ret2 = step_2({ hey: "oh" })
|
||||||
|
|
||||||
|
step_2({ hey: "async hello" }).config({
|
||||||
|
name: "new_step_name",
|
||||||
|
async: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return step_3(ret2)
|
||||||
|
}
|
||||||
|
)
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
StepResponse,
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
} from "@medusajs/workflows-sdk"
|
||||||
|
import { setTimeout } from "timers/promises"
|
||||||
|
|
||||||
|
const step_1 = createStep(
|
||||||
|
"step_1",
|
||||||
|
jest.fn(async (input) => {
|
||||||
|
await setTimeout(200)
|
||||||
|
|
||||||
|
return new StepResponse(input, { compensate: 123 })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const step_1_async = createStep(
|
||||||
|
{
|
||||||
|
name: "step_1_async",
|
||||||
|
async: true,
|
||||||
|
timeout: 0.1, // 0.1 second
|
||||||
|
},
|
||||||
|
|
||||||
|
jest.fn(async (input) => {
|
||||||
|
return new StepResponse(input, { compensate: 123 })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
createWorkflow(
|
||||||
|
{
|
||||||
|
name: "workflow_step_timeout",
|
||||||
|
},
|
||||||
|
function (input) {
|
||||||
|
const resp = step_1(input).config({
|
||||||
|
timeout: 0.1, // 0.1 second
|
||||||
|
})
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
createWorkflow(
|
||||||
|
{
|
||||||
|
name: "workflow_step_timeout_async",
|
||||||
|
},
|
||||||
|
function (input) {
|
||||||
|
const resp = step_1_async(input)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
)
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
StepResponse,
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
} from "@medusajs/workflows-sdk"
|
||||||
|
import { setTimeout } from "timers/promises"
|
||||||
|
|
||||||
|
const step_1 = createStep(
|
||||||
|
"step_1",
|
||||||
|
jest.fn(async (input) => {
|
||||||
|
await setTimeout(200)
|
||||||
|
|
||||||
|
return new StepResponse({
|
||||||
|
executed: true,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
jest.fn()
|
||||||
|
)
|
||||||
|
|
||||||
|
createWorkflow(
|
||||||
|
{
|
||||||
|
name: "workflow_transaction_timeout",
|
||||||
|
timeout: 0.1, // 0.1 second
|
||||||
|
},
|
||||||
|
function (input) {
|
||||||
|
const resp = step_1(input)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
createWorkflow(
|
||||||
|
{
|
||||||
|
name: "workflow_transaction_timeout_async",
|
||||||
|
timeout: 0.1, // 0.1 second
|
||||||
|
},
|
||||||
|
function (input) {
|
||||||
|
const resp = step_1(input).config({
|
||||||
|
async: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { MedusaApp } from "@medusajs/modules-sdk"
|
||||||
|
import {
|
||||||
|
TransactionStepTimeoutError,
|
||||||
|
TransactionTimeoutError,
|
||||||
|
} from "@medusajs/orchestration"
|
||||||
|
import { RemoteJoinerQuery } from "@medusajs/types"
|
||||||
|
import { TransactionHandlerType } from "@medusajs/utils"
|
||||||
|
import { IWorkflowsModuleService } from "@medusajs/workflows-sdk"
|
||||||
|
import { knex } from "knex"
|
||||||
|
import { setTimeout } from "timers/promises"
|
||||||
|
import "../__fixtures__"
|
||||||
|
import { DB_URL, TestDatabase } from "../utils"
|
||||||
|
|
||||||
|
const sharedPgConnection = knex<any, any>({
|
||||||
|
client: "pg",
|
||||||
|
searchPath: process.env.MEDUSA_WORKFLOW_ENGINE_DB_SCHEMA,
|
||||||
|
connection: {
|
||||||
|
connectionString: DB_URL,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const afterEach_ = async () => {
|
||||||
|
await TestDatabase.clearTables(sharedPgConnection)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Workflow Orchestrator module", function () {
|
||||||
|
describe("Testing basic workflow", function () {
|
||||||
|
let workflowOrcModule: IWorkflowsModuleService
|
||||||
|
let query: (
|
||||||
|
query: string | RemoteJoinerQuery | object,
|
||||||
|
variables?: Record<string, unknown>
|
||||||
|
) => Promise<any>
|
||||||
|
|
||||||
|
afterEach(afterEach_)
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const {
|
||||||
|
runMigrations,
|
||||||
|
query: remoteQuery,
|
||||||
|
modules,
|
||||||
|
} = await MedusaApp({
|
||||||
|
sharedResourcesConfig: {
|
||||||
|
database: {
|
||||||
|
connection: sharedPgConnection,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modulesConfig: {
|
||||||
|
workflows: {
|
||||||
|
resolve: __dirname + "/../..",
|
||||||
|
options: {
|
||||||
|
redis: {
|
||||||
|
url: "localhost:6379",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
query = remoteQuery
|
||||||
|
|
||||||
|
await runMigrations()
|
||||||
|
|
||||||
|
workflowOrcModule =
|
||||||
|
modules.workflows as unknown as IWorkflowsModuleService
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(afterEach_)
|
||||||
|
|
||||||
|
it("should return a list of workflow executions and remove after completed when there is no retentionTime set", async () => {
|
||||||
|
await workflowOrcModule.run("workflow_1", {
|
||||||
|
input: {
|
||||||
|
value: "123",
|
||||||
|
},
|
||||||
|
throwOnError: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
let executionsList = await query({
|
||||||
|
workflow_executions: {
|
||||||
|
fields: ["workflow_id", "transaction_id", "state"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executionsList).toHaveLength(1)
|
||||||
|
|
||||||
|
const { result } = await workflowOrcModule.setStepSuccess({
|
||||||
|
idempotencyKey: {
|
||||||
|
action: TransactionHandlerType.INVOKE,
|
||||||
|
stepId: "new_step_name",
|
||||||
|
workflowId: "workflow_1",
|
||||||
|
transactionId: executionsList[0].transaction_id,
|
||||||
|
},
|
||||||
|
stepResponse: { uhuuuu: "yeaah!" },
|
||||||
|
})
|
||||||
|
|
||||||
|
executionsList = await query({
|
||||||
|
workflow_executions: {
|
||||||
|
fields: ["id"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executionsList).toHaveLength(0)
|
||||||
|
expect(result).toEqual({
|
||||||
|
done: {
|
||||||
|
inputFromSyncStep: "oh",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return a list of workflow executions and keep it saved when there is a retentionTime set", async () => {
|
||||||
|
await workflowOrcModule.run("workflow_2", {
|
||||||
|
input: {
|
||||||
|
value: "123",
|
||||||
|
},
|
||||||
|
throwOnError: true,
|
||||||
|
transactionId: "transaction_1",
|
||||||
|
})
|
||||||
|
|
||||||
|
let executionsList = await query({
|
||||||
|
workflow_executions: {
|
||||||
|
fields: ["id"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executionsList).toHaveLength(1)
|
||||||
|
|
||||||
|
await workflowOrcModule.setStepSuccess({
|
||||||
|
idempotencyKey: {
|
||||||
|
action: TransactionHandlerType.INVOKE,
|
||||||
|
stepId: "new_step_name",
|
||||||
|
workflowId: "workflow_2",
|
||||||
|
transactionId: "transaction_1",
|
||||||
|
},
|
||||||
|
stepResponse: { uhuuuu: "yeaah!" },
|
||||||
|
})
|
||||||
|
|
||||||
|
executionsList = await query({
|
||||||
|
workflow_executions: {
|
||||||
|
fields: ["id"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executionsList).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should revert the entire transaction when a step timeout expires", async () => {
|
||||||
|
const { transaction, result, errors } = await workflowOrcModule.run(
|
||||||
|
"workflow_step_timeout",
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
myInput: "123",
|
||||||
|
},
|
||||||
|
throwOnError: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(transaction.flow.state).toEqual("reverted")
|
||||||
|
expect(result).toEqual({
|
||||||
|
myInput: "123",
|
||||||
|
})
|
||||||
|
expect(errors).toHaveLength(1)
|
||||||
|
expect(errors[0].action).toEqual("step_1")
|
||||||
|
expect(errors[0].error).toBeInstanceOf(TransactionStepTimeoutError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should revert the entire transaction when the transaction timeout expires", async () => {
|
||||||
|
const { transaction, result, errors } = await workflowOrcModule.run(
|
||||||
|
"workflow_transaction_timeout",
|
||||||
|
{
|
||||||
|
input: {},
|
||||||
|
transactionId: "trx",
|
||||||
|
throwOnError: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(transaction.flow.state).toEqual("reverted")
|
||||||
|
expect(result).toEqual({ executed: true })
|
||||||
|
expect(errors).toHaveLength(1)
|
||||||
|
expect(errors[0].action).toEqual("step_1")
|
||||||
|
expect(
|
||||||
|
TransactionTimeoutError.isTransactionTimeoutError(errors[0].error)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should revert the entire transaction when a step timeout expires in a async step", async () => {
|
||||||
|
await workflowOrcModule.run("workflow_step_timeout_async", {
|
||||||
|
input: {
|
||||||
|
myInput: "123",
|
||||||
|
},
|
||||||
|
transactionId: "transaction_1",
|
||||||
|
throwOnError: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await setTimeout(200)
|
||||||
|
|
||||||
|
const { transaction, result, errors } = await workflowOrcModule.run(
|
||||||
|
"workflow_step_timeout_async",
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
myInput: "123",
|
||||||
|
},
|
||||||
|
transactionId: "transaction_1",
|
||||||
|
throwOnError: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(transaction.flow.state).toEqual("reverted")
|
||||||
|
expect(result).toEqual(undefined)
|
||||||
|
expect(errors).toHaveLength(1)
|
||||||
|
expect(errors[0].action).toEqual("step_1_async")
|
||||||
|
expect(
|
||||||
|
TransactionStepTimeoutError.isTransactionStepTimeoutError(
|
||||||
|
errors[0].error
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should revert the entire transaction when the transaction timeout expires in a transaction containing an async step", async () => {
|
||||||
|
await workflowOrcModule.run("workflow_transaction_timeout_async", {
|
||||||
|
input: {},
|
||||||
|
transactionId: "transaction_1",
|
||||||
|
throwOnError: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await setTimeout(200)
|
||||||
|
|
||||||
|
const { transaction, result, errors } = await workflowOrcModule.run(
|
||||||
|
"workflow_transaction_timeout_async",
|
||||||
|
{
|
||||||
|
input: {},
|
||||||
|
transactionId: "transaction_1",
|
||||||
|
throwOnError: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(transaction.flow.state).toEqual("reverted")
|
||||||
|
expect(result).toEqual(undefined)
|
||||||
|
expect(errors).toHaveLength(1)
|
||||||
|
expect(errors[0].action).toEqual("step_1")
|
||||||
|
expect(
|
||||||
|
TransactionTimeoutError.isTransactionTimeoutError(errors[0].error)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
if (typeof process.env.DB_TEMP_NAME === "undefined") {
|
||||||
|
const tempName = parseInt(process.env.JEST_WORKER_ID || "1")
|
||||||
|
process.env.DB_TEMP_NAME = `medusa-workflow-engine-redis-${tempName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.MEDUSA_WORKFLOW_ENGINE_DB_SCHEMA = "public"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { JestUtils } from "medusa-test-utils"
|
||||||
|
|
||||||
|
JestUtils.afterAllHookDropDatabase()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import * as process from "process"
|
||||||
|
|
||||||
|
const DB_HOST = process.env.DB_HOST ?? "localhost"
|
||||||
|
const DB_USERNAME = process.env.DB_USERNAME ?? ""
|
||||||
|
const DB_PASSWORD = process.env.DB_PASSWORD
|
||||||
|
const DB_NAME = process.env.DB_TEMP_NAME
|
||||||
|
|
||||||
|
export const DB_URL = `postgres://${DB_USERNAME}${
|
||||||
|
DB_PASSWORD ? `:${DB_PASSWORD}` : ""
|
||||||
|
}@${DB_HOST}/${DB_NAME}`
|
||||||
|
|
||||||
|
const Redis = require("ioredis")
|
||||||
|
|
||||||
|
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379"
|
||||||
|
const redis = new Redis(redisUrl)
|
||||||
|
|
||||||
|
interface TestDatabase {
|
||||||
|
clearTables(knex): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TestDatabase: TestDatabase = {
|
||||||
|
clearTables: async (knex) => {
|
||||||
|
await knex.raw(`
|
||||||
|
TRUNCATE TABLE workflow_execution CASCADE;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await cleanRedis()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteKeysByPattern(pattern) {
|
||||||
|
const stream = redis.scanStream({
|
||||||
|
match: pattern,
|
||||||
|
count: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
for await (const keys of stream) {
|
||||||
|
if (keys.length) {
|
||||||
|
const pipeline = redis.pipeline()
|
||||||
|
keys.forEach((key) => pipeline.del(key))
|
||||||
|
await pipeline.exec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanRedis() {
|
||||||
|
try {
|
||||||
|
await deleteKeysByPattern("bull:*")
|
||||||
|
await deleteKeysByPattern("dtrans:*")
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./database"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
module.exports = {
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^@models": "<rootDir>/src/models",
|
||||||
|
"^@services": "<rootDir>/src/services",
|
||||||
|
"^@repositories": "<rootDir>/src/repositories",
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
"^.+\\.[jt]s?$": [
|
||||||
|
"ts-jest",
|
||||||
|
{
|
||||||
|
tsConfig: "tsconfig.spec.json",
|
||||||
|
isolatedModules: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
testEnvironment: `node`,
|
||||||
|
moduleFileExtensions: [`js`, `ts`],
|
||||||
|
modulePathIgnorePatterns: ["dist/"],
|
||||||
|
setupFiles: ["<rootDir>/integration-tests/setup-env.js"],
|
||||||
|
setupFilesAfterEnv: ["<rootDir>/integration-tests/setup.js"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import * as entities from "./src/models"
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entities: Object.values(entities),
|
||||||
|
schema: "public",
|
||||||
|
clientUrl: "postgres://postgres@localhost/medusa-workflow-engine-redis",
|
||||||
|
type: "postgresql",
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "@medusajs/workflow-engine-redis",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Medusa Workflow Orchestrator module using Redis to track workflows executions",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/medusajs/medusa",
|
||||||
|
"directory": "packages/workflow-engine-redis"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"author": "Medusa",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"watch": "tsc --build --watch",
|
||||||
|
"watch:test": "tsc --build tsconfig.spec.json --watch",
|
||||||
|
"prepublishOnly": "cross-env NODE_ENV=production tsc --build && tsc-alias -p tsconfig.json",
|
||||||
|
"build": "rimraf dist && tsc --build && tsc-alias -p tsconfig.json",
|
||||||
|
"test": "jest --passWithNoTests --runInBand --bail --forceExit -- src/**/__tests__/**/*.ts",
|
||||||
|
"test:integration": "jest --runInBand --forceExit -- integration-tests/**/__tests__/**/*.ts",
|
||||||
|
"migration:generate": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:generate",
|
||||||
|
"migration:initial": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create --initial",
|
||||||
|
"migration:create": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create",
|
||||||
|
"migration:up": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:up",
|
||||||
|
"orm:cache:clear": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm cache:clear"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mikro-orm/cli": "5.9.7",
|
||||||
|
"cross-env": "^5.2.1",
|
||||||
|
"jest": "^29.6.3",
|
||||||
|
"medusa-test-utils": "^1.1.40",
|
||||||
|
"rimraf": "^3.0.2",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tsc-alias": "^1.8.6",
|
||||||
|
"typescript": "^5.1.6"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@medusajs/modules-sdk": "^1.12.5",
|
||||||
|
"@medusajs/types": "^1.11.9",
|
||||||
|
"@medusajs/utils": "^1.11.2",
|
||||||
|
"@medusajs/workflows-sdk": "^0.1.0",
|
||||||
|
"@mikro-orm/core": "5.9.7",
|
||||||
|
"@mikro-orm/migrations": "5.9.7",
|
||||||
|
"@mikro-orm/postgresql": "5.9.7",
|
||||||
|
"awilix": "^8.0.0",
|
||||||
|
"bullmq": "^5.1.3",
|
||||||
|
"dotenv": "^16.1.4",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"knex": "2.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Modules } from "@medusajs/modules-sdk"
|
||||||
|
import { ModulesSdkUtils } from "@medusajs/utils"
|
||||||
|
import * as models from "@models"
|
||||||
|
import { moduleDefinition } from "./module-definition"
|
||||||
|
|
||||||
|
export default moduleDefinition
|
||||||
|
|
||||||
|
const migrationScriptOptions = {
|
||||||
|
moduleName: Modules.WORKFLOW_ENGINE,
|
||||||
|
models: models,
|
||||||
|
pathToMigrations: __dirname + "/migrations",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runMigrations = ModulesSdkUtils.buildMigrationScript(
|
||||||
|
migrationScriptOptions
|
||||||
|
)
|
||||||
|
export const revertMigration = ModulesSdkUtils.buildRevertMigrationScript(
|
||||||
|
migrationScriptOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
export * from "./initialize"
|
||||||
|
export * from "./loaders"
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
ExternalModuleDeclaration,
|
||||||
|
InternalModuleDeclaration,
|
||||||
|
MedusaModule,
|
||||||
|
MODULE_PACKAGE_NAMES,
|
||||||
|
Modules,
|
||||||
|
} from "@medusajs/modules-sdk"
|
||||||
|
import { ModulesSdkTypes } from "@medusajs/types"
|
||||||
|
import { WorkflowOrchestratorTypes } from "@medusajs/workflows-sdk"
|
||||||
|
import { moduleDefinition } from "../module-definition"
|
||||||
|
import { InitializeModuleInjectableDependencies } from "../types"
|
||||||
|
|
||||||
|
export const initialize = async (
|
||||||
|
options?:
|
||||||
|
| ModulesSdkTypes.ModuleServiceInitializeOptions
|
||||||
|
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
|
||||||
|
| ExternalModuleDeclaration
|
||||||
|
| InternalModuleDeclaration,
|
||||||
|
injectedDependencies?: InitializeModuleInjectableDependencies
|
||||||
|
): Promise<WorkflowOrchestratorTypes.IWorkflowsModuleService> => {
|
||||||
|
const loaded =
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
await MedusaModule.bootstrap<WorkflowOrchestratorTypes.IWorkflowsModuleService>(
|
||||||
|
{
|
||||||
|
moduleKey: Modules.WORKFLOW_ENGINE,
|
||||||
|
defaultPath: MODULE_PACKAGE_NAMES[Modules.WORKFLOW_ENGINE],
|
||||||
|
declaration: options as
|
||||||
|
| InternalModuleDeclaration
|
||||||
|
| ExternalModuleDeclaration,
|
||||||
|
injectedDependencies,
|
||||||
|
moduleExports: moduleDefinition,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return loaded[Modules.WORKFLOW_ENGINE]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Modules } from "@medusajs/modules-sdk"
|
||||||
|
import { ModuleJoinerConfig } from "@medusajs/types"
|
||||||
|
import { MapToConfig } from "@medusajs/utils"
|
||||||
|
import { WorkflowExecution } from "@models"
|
||||||
|
import moduleSchema from "./schema"
|
||||||
|
|
||||||
|
export const LinkableKeys = {
|
||||||
|
workflow_execution_id: WorkflowExecution.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityLinkableKeysMap: MapToConfig = {}
|
||||||
|
Object.entries(LinkableKeys).forEach(([key, value]) => {
|
||||||
|
entityLinkableKeysMap[value] ??= []
|
||||||
|
entityLinkableKeysMap[value].push({
|
||||||
|
mapTo: key,
|
||||||
|
valueFrom: key.split("_").pop()!,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const entityNameToLinkableKeysMap: MapToConfig = entityLinkableKeysMap
|
||||||
|
|
||||||
|
export const joinerConfig: ModuleJoinerConfig = {
|
||||||
|
serviceName: Modules.WORKFLOW_ENGINE,
|
||||||
|
primaryKeys: ["id"],
|
||||||
|
schema: moduleSchema,
|
||||||
|
linkableKeys: LinkableKeys,
|
||||||
|
alias: {
|
||||||
|
name: ["workflow_execution", "workflow_executions"],
|
||||||
|
args: {
|
||||||
|
entity: WorkflowExecution.name,
|
||||||
|
methodSuffix: "WorkflowExecution",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
InternalModuleDeclaration,
|
||||||
|
LoaderOptions,
|
||||||
|
Modules,
|
||||||
|
} from "@medusajs/modules-sdk"
|
||||||
|
import { ModulesSdkTypes } from "@medusajs/types"
|
||||||
|
import { ModulesSdkUtils } from "@medusajs/utils"
|
||||||
|
import { EntitySchema } from "@mikro-orm/core"
|
||||||
|
import * as WorkflowOrchestratorModels from "../models"
|
||||||
|
|
||||||
|
export default async (
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
container,
|
||||||
|
logger,
|
||||||
|
}: LoaderOptions<
|
||||||
|
| ModulesSdkTypes.ModuleServiceInitializeOptions
|
||||||
|
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
|
||||||
|
>,
|
||||||
|
moduleDeclaration?: InternalModuleDeclaration
|
||||||
|
): Promise<void> => {
|
||||||
|
const entities = Object.values(
|
||||||
|
WorkflowOrchestratorModels
|
||||||
|
) as unknown as EntitySchema[]
|
||||||
|
const pathToMigrations = __dirname + "/../migrations"
|
||||||
|
|
||||||
|
await ModulesSdkUtils.mikroOrmConnectionLoader({
|
||||||
|
moduleName: Modules.WORKFLOW_ENGINE,
|
||||||
|
entities,
|
||||||
|
container,
|
||||||
|
options,
|
||||||
|
moduleDeclaration,
|
||||||
|
logger,
|
||||||
|
pathToMigrations,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { MikroOrmBaseRepository, ModulesSdkUtils } from "@medusajs/utils"
|
||||||
|
import * as ModuleModels from "@models"
|
||||||
|
import * as ModuleServices from "@services"
|
||||||
|
|
||||||
|
export default ModulesSdkUtils.moduleContainerLoaderFactory({
|
||||||
|
moduleModels: ModuleModels,
|
||||||
|
moduleServices: ModuleServices,
|
||||||
|
moduleRepositories: { BaseRepository: MikroOrmBaseRepository },
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./connection"
|
||||||
|
export * from "./container"
|
||||||
|
export * from "./redis"
|
||||||
|
export * from "./utils"
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { LoaderOptions } from "@medusajs/modules-sdk"
|
||||||
|
import { asValue } from "awilix"
|
||||||
|
import Redis from "ioredis"
|
||||||
|
import { RedisWorkflowsOptions } from "../types"
|
||||||
|
|
||||||
|
export default async ({
|
||||||
|
container,
|
||||||
|
logger,
|
||||||
|
options,
|
||||||
|
}: LoaderOptions): Promise<void> => {
|
||||||
|
const {
|
||||||
|
url,
|
||||||
|
options: redisOptions,
|
||||||
|
pubsub,
|
||||||
|
} = options?.redis as RedisWorkflowsOptions
|
||||||
|
|
||||||
|
// TODO: get default from ENV VAR
|
||||||
|
if (!url) {
|
||||||
|
throw Error(
|
||||||
|
"No `redis.url` provided in `workflowOrchestrator` module options. It is required for the Workflow Orchestrator Redis."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cnnPubSub = pubsub ?? { url, options: redisOptions }
|
||||||
|
|
||||||
|
const queueName = options?.queueName ?? "medusa-workflows"
|
||||||
|
|
||||||
|
let connection
|
||||||
|
let redisPublisher
|
||||||
|
let redisSubscriber
|
||||||
|
let workerConnection
|
||||||
|
|
||||||
|
try {
|
||||||
|
connection = await getConnection(url, redisOptions)
|
||||||
|
workerConnection = await getConnection(url, {
|
||||||
|
...(redisOptions ?? {}),
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
})
|
||||||
|
logger?.info(
|
||||||
|
`Connection to Redis in module 'workflow-engine-redis' established`
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
logger?.error(
|
||||||
|
`An error occurred while connecting to Redis in module 'workflow-engine-redis': ${err}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
redisPublisher = await getConnection(cnnPubSub.url, cnnPubSub.options)
|
||||||
|
redisSubscriber = await getConnection(cnnPubSub.url, cnnPubSub.options)
|
||||||
|
logger?.info(
|
||||||
|
`Connection to Redis PubSub in module 'workflow-engine-redis' established`
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
logger?.error(
|
||||||
|
`An error occurred while connecting to Redis PubSub in module 'workflow-engine-redis': ${err}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
container.register({
|
||||||
|
redisConnection: asValue(connection),
|
||||||
|
redisWorkerConnection: asValue(workerConnection),
|
||||||
|
redisPublisher: asValue(redisPublisher),
|
||||||
|
redisSubscriber: asValue(redisSubscriber),
|
||||||
|
redisQueueName: asValue(queueName),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getConnection(url, redisOptions) {
|
||||||
|
const connection = new Redis(url, {
|
||||||
|
lazyConnect: true,
|
||||||
|
...(redisOptions ?? {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await connection.connect()
|
||||||
|
|
||||||
|
return connection
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { asClass } from "awilix"
|
||||||
|
import { RedisDistributedTransactionStorage } from "../utils"
|
||||||
|
|
||||||
|
export default async ({ container }): Promise<void> => {
|
||||||
|
container.register({
|
||||||
|
redisDistributedTransactionStorage: asClass(
|
||||||
|
RedisDistributedTransactionStorage
|
||||||
|
).singleton(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Migration } from "@mikro-orm/migrations"
|
||||||
|
|
||||||
|
export class Migration20231221104256 extends Migration {
|
||||||
|
async up(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_execution
|
||||||
|
(
|
||||||
|
id character varying NOT NULL,
|
||||||
|
workflow_id character varying NOT NULL,
|
||||||
|
transaction_id character varying NOT NULL,
|
||||||
|
execution jsonb NULL,
|
||||||
|
context jsonb NULL,
|
||||||
|
state character varying NOT NULL,
|
||||||
|
created_at timestamp WITHOUT time zone NOT NULL DEFAULT Now(),
|
||||||
|
updated_at timestamp WITHOUT time zone NOT NULL DEFAULT Now(),
|
||||||
|
deleted_at timestamp WITHOUT time zone NULL,
|
||||||
|
CONSTRAINT "PK_workflow_execution_workflow_id_transaction_id" PRIMARY KEY ("workflow_id", "transaction_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_workflow_execution_id" ON "workflow_execution" ("id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_workflow_id" ON "workflow_execution" ("workflow_id") WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_transaction_id" ON "workflow_execution" ("transaction_id") WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_state" ON "workflow_execution" ("state") WHERE deleted_at IS NULL;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
`
|
||||||
|
DROP INDEX "IDX_workflow_execution_id";
|
||||||
|
DROP INDEX "IDX_workflow_execution_workflow_id";
|
||||||
|
DROP INDEX "IDX_workflow_execution_transaction_id";
|
||||||
|
DROP INDEX "IDX_workflow_execution_state";
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS workflow_execution;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as WorkflowExecution } from "./workflow-execution"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { TransactionState } from "@medusajs/orchestration"
|
||||||
|
import { DALUtils, generateEntityId } from "@medusajs/utils"
|
||||||
|
import {
|
||||||
|
BeforeCreate,
|
||||||
|
Entity,
|
||||||
|
Enum,
|
||||||
|
Filter,
|
||||||
|
Index,
|
||||||
|
OnInit,
|
||||||
|
OptionalProps,
|
||||||
|
PrimaryKey,
|
||||||
|
Property,
|
||||||
|
Unique,
|
||||||
|
} from "@mikro-orm/core"
|
||||||
|
|
||||||
|
type OptionalFields = "deleted_at"
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
@Unique({
|
||||||
|
name: "IDX_workflow_execution_workflow_id_transaction_id_unique",
|
||||||
|
properties: ["workflow_id", "transaction_id"],
|
||||||
|
})
|
||||||
|
@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions)
|
||||||
|
export default class WorkflowExecution {
|
||||||
|
[OptionalProps]?: OptionalFields
|
||||||
|
|
||||||
|
@Property({ columnType: "text", nullable: false })
|
||||||
|
@Index({ name: "IDX_workflow_execution_id" })
|
||||||
|
id!: string
|
||||||
|
|
||||||
|
@Index({ name: "IDX_workflow_execution_workflow_id" })
|
||||||
|
@PrimaryKey({ columnType: "text" })
|
||||||
|
workflow_id: string
|
||||||
|
|
||||||
|
@Index({ name: "IDX_workflow_execution_transaction_id" })
|
||||||
|
@PrimaryKey({ columnType: "text" })
|
||||||
|
transaction_id: string
|
||||||
|
|
||||||
|
@Property({ columnType: "jsonb", nullable: true })
|
||||||
|
execution: Record<string, unknown> | null = null
|
||||||
|
|
||||||
|
@Property({ columnType: "jsonb", nullable: true })
|
||||||
|
context: Record<string, unknown> | null = null
|
||||||
|
|
||||||
|
@Index({ name: "IDX_workflow_execution_state" })
|
||||||
|
@Enum(() => TransactionState)
|
||||||
|
state: TransactionState
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
onCreate: () => new Date(),
|
||||||
|
columnType: "timestamptz",
|
||||||
|
defaultRaw: "now()",
|
||||||
|
})
|
||||||
|
created_at: Date
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
onCreate: () => new Date(),
|
||||||
|
onUpdate: () => new Date(),
|
||||||
|
columnType: "timestamptz",
|
||||||
|
defaultRaw: "now()",
|
||||||
|
})
|
||||||
|
updated_at: Date
|
||||||
|
|
||||||
|
@Property({ columnType: "timestamptz", nullable: true })
|
||||||
|
deleted_at: Date | null = null
|
||||||
|
|
||||||
|
@BeforeCreate()
|
||||||
|
onCreate() {
|
||||||
|
this.id = generateEntityId(this.id, "wf_exec")
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnInit()
|
||||||
|
onInit() {
|
||||||
|
this.id = generateEntityId(this.id, "wf_exec")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ModuleExports } from "@medusajs/types"
|
||||||
|
import { WorkflowsModuleService } from "@services"
|
||||||
|
import loadConnection from "./loaders/connection"
|
||||||
|
import loadContainer from "./loaders/container"
|
||||||
|
import redisConnection from "./loaders/redis"
|
||||||
|
import loadUtils from "./loaders/utils"
|
||||||
|
|
||||||
|
const service = WorkflowsModuleService
|
||||||
|
const loaders = [
|
||||||
|
loadContainer,
|
||||||
|
loadConnection,
|
||||||
|
loadUtils,
|
||||||
|
redisConnection,
|
||||||
|
] as any
|
||||||
|
|
||||||
|
export const moduleDefinition: ModuleExports = {
|
||||||
|
service,
|
||||||
|
loaders,
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils"
|
||||||
|
export { WorkflowExecutionRepository } from "./workflow-execution"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { DALUtils } from "@medusajs/utils"
|
||||||
|
import { WorkflowExecution } from "@models"
|
||||||
|
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
export class WorkflowExecutionRepository extends DALUtils.mikroOrmBaseRepositoryFactory(
|
||||||
|
WorkflowExecution
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export default `
|
||||||
|
scalar DateTime
|
||||||
|
scalar JSON
|
||||||
|
|
||||||
|
enum TransactionState {
|
||||||
|
NOT_STARTED
|
||||||
|
INVOKING
|
||||||
|
WAITING_TO_COMPENSATE
|
||||||
|
COMPENSATING
|
||||||
|
DONE
|
||||||
|
REVERTED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowExecution {
|
||||||
|
id: ID!
|
||||||
|
created_at: DateTime!
|
||||||
|
updated_at: DateTime!
|
||||||
|
deleted_at: DateTime
|
||||||
|
workflow_id: string
|
||||||
|
transaction_id: string
|
||||||
|
execution: JSON
|
||||||
|
context: JSON
|
||||||
|
state: TransactionState
|
||||||
|
}
|
||||||
|
`
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
describe("Noop test", () => {
|
||||||
|
it("noop check", async () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./workflow-execution"
|
||||||
|
export * from "./workflow-orchestrator"
|
||||||
|
export * from "./workflows-module"
|
||||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user