From 302323916b6d8eaf571cd59b5fc92a913af207de Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Tue, 23 Jan 2024 10:08:08 -0300 Subject: [PATCH] feat: Workflow engine modules (#6128) --- .changeset/pink-balloons-search.md | 11 + .eslintignore | 3 + .eslintrc.js | 6 +- package.json | 12 +- packages/medusa/src/api/routes/admin/index.js | 11 +- .../routing/__fixtures__/server/index.js | 2 + .../medusajs-modules-sdk-1.12.6.tgz | Bin 0 -> 28833 bytes packages/modules-sdk/src/definitions.ts | 17 + packages/modules-sdk/src/medusa-app.ts | 6 + .../transaction/transaction-orchestrator.ts | 454 ++++++++++++++ .../transaction/datastore/abstract-storage.ts | 11 - .../datastore/base-in-memory-storage.ts | 20 +- .../transaction/distributed-transaction.ts | 53 +- .../orchestration/src/transaction/errors.ts | 15 +- .../src/transaction/orchestrator-builder.ts | 4 +- .../transaction/transaction-orchestrator.ts | 320 +++++++--- .../src/transaction/transaction-step.ts | 46 +- .../orchestration/src/transaction/types.ts | 121 +++- .../src/workflow/workflow-manager.ts | 20 +- packages/types/src/bundles.ts | 1 - packages/types/src/dal/repository-service.ts | 2 +- packages/types/src/index.ts | 2 +- .../mikro-orm/mikro-orm-create-connection.ts | 4 +- .../utils/src/modules-sdk/decorators/index.ts | 2 +- .../load-module-database-config.ts | 2 +- packages/utils/src/orchestration/index.ts | 1 + packages/utils/src/orchestration/types.ts | 34 ++ packages/workflow-engine-inmemory/.gitignore | 6 + .../workflow-engine-inmemory/CHANGELOG.md | 0 packages/workflow-engine-inmemory/README.md | 1 + .../integration-tests/__fixtures__/index.ts | 4 + .../__fixtures__/workflow_1.ts | 65 ++ .../__fixtures__/workflow_2.ts | 71 +++ .../__fixtures__/workflow_step_timeout.ts | 29 + .../workflow_transaction_timeout.ts | 36 ++ .../integration-tests/__tests__/index.spec.ts | 163 +++++ .../integration-tests/setup-env.js | 6 + .../integration-tests/setup.js | 3 + .../integration-tests/utils/database.ts | 22 + .../integration-tests/utils/index.ts | 1 + .../workflow-engine-inmemory/jest.config.js | 22 + .../mikro-orm.config.dev.ts | 8 + .../workflow-engine-inmemory/package.json | 59 ++ .../workflow-engine-inmemory/src/index.ts | 22 + .../src/initialize/index.ts | 36 ++ .../src/joiner-config.ts | 34 ++ .../src/loaders/connection.ts | 36 ++ .../src/loaders/container.ts | 9 + .../src/loaders/index.ts | 3 + .../src/loaders/utils.ts | 10 + .../src/migrations/Migration20231228143900.ts | 41 ++ .../src/models/index.ts | 1 + .../src/models/workflow-execution.ts | 76 +++ .../src/module-definition.ts | 13 + .../src/repositories/index.ts | 2 + .../src/repositories/workflow-execution.ts | 7 + .../src/schema/index.ts | 26 + .../src/services/__tests__/index.spec.ts | 5 + .../src/services/index.ts | 3 + .../src/services/workflow-execution.ts | 21 + .../src/services/workflow-orchestrator.ts | 528 ++++++++++++++++ .../src/services/workflows-module.ts | 199 ++++++ .../src/types/index.ts | 5 + .../src/utils/index.ts | 1 + .../utils/workflow-orchestrator-storage.ts | 201 ++++++ .../workflow-engine-inmemory/tsconfig.json | 38 ++ .../tsconfig.spec.json | 8 + packages/workflow-engine-redis/.gitignore | 6 + packages/workflow-engine-redis/CHANGELOG.md | 0 packages/workflow-engine-redis/README.md | 1 + .../integration-tests/__fixtures__/index.ts | 4 + .../__fixtures__/workflow_1.ts | 65 ++ .../__fixtures__/workflow_2.ts | 71 +++ .../__fixtures__/workflow_step_timeout.ts | 51 ++ .../workflow_transaction_timeout.ts | 44 ++ .../integration-tests/__tests__/index.spec.ts | 245 ++++++++ .../integration-tests/setup-env.js | 6 + .../integration-tests/setup.js | 3 + .../integration-tests/utils/database.ts | 53 ++ .../integration-tests/utils/index.ts | 1 + packages/workflow-engine-redis/jest.config.js | 21 + .../mikro-orm.config.dev.ts | 8 + packages/workflow-engine-redis/package.json | 61 ++ packages/workflow-engine-redis/src/index.ts | 22 + .../src/initialize/index.ts | 36 ++ .../src/joiner-config.ts | 34 ++ .../src/loaders/connection.ts | 36 ++ .../src/loaders/container.ts | 9 + .../src/loaders/index.ts | 4 + .../src/loaders/redis.ts | 78 +++ .../src/loaders/utils.ts | 10 + .../src/migrations/Migration20231228143900.ts | 41 ++ .../workflow-engine-redis/src/models/index.ts | 1 + .../src/models/workflow-execution.ts | 76 +++ .../src/module-definition.ts | 19 + .../src/repositories/index.ts | 2 + .../src/repositories/workflow-execution.ts | 7 + .../workflow-engine-redis/src/schema/index.ts | 26 + .../src/services/__tests__/index.spec.ts | 5 + .../src/services/index.ts | 3 + .../src/services/workflow-execution.ts | 21 + .../src/services/workflow-orchestrator.ts | 577 ++++++++++++++++++ .../src/services/workflows-module.ts | 199 ++++++ .../workflow-engine-redis/src/types/index.ts | 34 ++ .../workflow-engine-redis/src/utils/index.ts | 1 + .../utils/workflow-orchestrator-storage.ts | 304 +++++++++ packages/workflow-engine-redis/tsconfig.json | 38 ++ .../workflow-engine-redis/tsconfig.spec.json | 8 + .../src/helper/__tests__}/compose.ts | 2 +- packages/workflows-sdk/src/index.ts | 1 + packages/workflows-sdk/src/types/common.ts | 21 + packages/workflows-sdk/src/types/index.ts | 3 + packages/workflows-sdk/src/types/mutations.ts | 7 + packages/workflows-sdk/src/types/service.ts | 116 ++++ .../workflows-sdk/src/utils/_playground.ts | 13 + .../src/utils/composer/create-step.ts | 47 +- .../src/utils/composer/create-workflow.ts | 42 +- .../workflows-sdk/src/utils/composer/type.ts | 26 +- yarn.lock | 92 ++- 119 files changed, 5339 insertions(+), 263 deletions(-) create mode 100644 .changeset/pink-balloons-search.md create mode 100644 packages/modules-sdk/medusajs-modules-sdk-1.12.6.tgz create mode 100644 packages/utils/src/orchestration/types.ts create mode 100644 packages/workflow-engine-inmemory/.gitignore create mode 100644 packages/workflow-engine-inmemory/CHANGELOG.md create mode 100644 packages/workflow-engine-inmemory/README.md create mode 100644 packages/workflow-engine-inmemory/integration-tests/__fixtures__/index.ts create mode 100644 packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_1.ts create mode 100644 packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_2.ts create mode 100644 packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_step_timeout.ts create mode 100644 packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_transaction_timeout.ts create mode 100644 packages/workflow-engine-inmemory/integration-tests/__tests__/index.spec.ts create mode 100644 packages/workflow-engine-inmemory/integration-tests/setup-env.js create mode 100644 packages/workflow-engine-inmemory/integration-tests/setup.js create mode 100644 packages/workflow-engine-inmemory/integration-tests/utils/database.ts create mode 100644 packages/workflow-engine-inmemory/integration-tests/utils/index.ts create mode 100644 packages/workflow-engine-inmemory/jest.config.js create mode 100644 packages/workflow-engine-inmemory/mikro-orm.config.dev.ts create mode 100644 packages/workflow-engine-inmemory/package.json create mode 100644 packages/workflow-engine-inmemory/src/index.ts create mode 100644 packages/workflow-engine-inmemory/src/initialize/index.ts create mode 100644 packages/workflow-engine-inmemory/src/joiner-config.ts create mode 100644 packages/workflow-engine-inmemory/src/loaders/connection.ts create mode 100644 packages/workflow-engine-inmemory/src/loaders/container.ts create mode 100644 packages/workflow-engine-inmemory/src/loaders/index.ts create mode 100644 packages/workflow-engine-inmemory/src/loaders/utils.ts create mode 100644 packages/workflow-engine-inmemory/src/migrations/Migration20231228143900.ts create mode 100644 packages/workflow-engine-inmemory/src/models/index.ts create mode 100644 packages/workflow-engine-inmemory/src/models/workflow-execution.ts create mode 100644 packages/workflow-engine-inmemory/src/module-definition.ts create mode 100644 packages/workflow-engine-inmemory/src/repositories/index.ts create mode 100644 packages/workflow-engine-inmemory/src/repositories/workflow-execution.ts create mode 100644 packages/workflow-engine-inmemory/src/schema/index.ts create mode 100644 packages/workflow-engine-inmemory/src/services/__tests__/index.spec.ts create mode 100644 packages/workflow-engine-inmemory/src/services/index.ts create mode 100644 packages/workflow-engine-inmemory/src/services/workflow-execution.ts create mode 100644 packages/workflow-engine-inmemory/src/services/workflow-orchestrator.ts create mode 100644 packages/workflow-engine-inmemory/src/services/workflows-module.ts create mode 100644 packages/workflow-engine-inmemory/src/types/index.ts create mode 100644 packages/workflow-engine-inmemory/src/utils/index.ts create mode 100644 packages/workflow-engine-inmemory/src/utils/workflow-orchestrator-storage.ts create mode 100644 packages/workflow-engine-inmemory/tsconfig.json create mode 100644 packages/workflow-engine-inmemory/tsconfig.spec.json create mode 100644 packages/workflow-engine-redis/.gitignore create mode 100644 packages/workflow-engine-redis/CHANGELOG.md create mode 100644 packages/workflow-engine-redis/README.md create mode 100644 packages/workflow-engine-redis/integration-tests/__fixtures__/index.ts create mode 100644 packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_1.ts create mode 100644 packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_2.ts create mode 100644 packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_step_timeout.ts create mode 100644 packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_transaction_timeout.ts create mode 100644 packages/workflow-engine-redis/integration-tests/__tests__/index.spec.ts create mode 100644 packages/workflow-engine-redis/integration-tests/setup-env.js create mode 100644 packages/workflow-engine-redis/integration-tests/setup.js create mode 100644 packages/workflow-engine-redis/integration-tests/utils/database.ts create mode 100644 packages/workflow-engine-redis/integration-tests/utils/index.ts create mode 100644 packages/workflow-engine-redis/jest.config.js create mode 100644 packages/workflow-engine-redis/mikro-orm.config.dev.ts create mode 100644 packages/workflow-engine-redis/package.json create mode 100644 packages/workflow-engine-redis/src/index.ts create mode 100644 packages/workflow-engine-redis/src/initialize/index.ts create mode 100644 packages/workflow-engine-redis/src/joiner-config.ts create mode 100644 packages/workflow-engine-redis/src/loaders/connection.ts create mode 100644 packages/workflow-engine-redis/src/loaders/container.ts create mode 100644 packages/workflow-engine-redis/src/loaders/index.ts create mode 100644 packages/workflow-engine-redis/src/loaders/redis.ts create mode 100644 packages/workflow-engine-redis/src/loaders/utils.ts create mode 100644 packages/workflow-engine-redis/src/migrations/Migration20231228143900.ts create mode 100644 packages/workflow-engine-redis/src/models/index.ts create mode 100644 packages/workflow-engine-redis/src/models/workflow-execution.ts create mode 100644 packages/workflow-engine-redis/src/module-definition.ts create mode 100644 packages/workflow-engine-redis/src/repositories/index.ts create mode 100644 packages/workflow-engine-redis/src/repositories/workflow-execution.ts create mode 100644 packages/workflow-engine-redis/src/schema/index.ts create mode 100644 packages/workflow-engine-redis/src/services/__tests__/index.spec.ts create mode 100644 packages/workflow-engine-redis/src/services/index.ts create mode 100644 packages/workflow-engine-redis/src/services/workflow-execution.ts create mode 100644 packages/workflow-engine-redis/src/services/workflow-orchestrator.ts create mode 100644 packages/workflow-engine-redis/src/services/workflows-module.ts create mode 100644 packages/workflow-engine-redis/src/types/index.ts create mode 100644 packages/workflow-engine-redis/src/utils/index.ts create mode 100644 packages/workflow-engine-redis/src/utils/workflow-orchestrator-storage.ts create mode 100644 packages/workflow-engine-redis/tsconfig.json create mode 100644 packages/workflow-engine-redis/tsconfig.spec.json rename {integration-tests/plugins/__tests__/workflows/utils/composer => packages/workflows-sdk/src/helper/__tests__}/compose.ts (99%) create mode 100644 packages/workflows-sdk/src/types/common.ts create mode 100644 packages/workflows-sdk/src/types/index.ts create mode 100644 packages/workflows-sdk/src/types/mutations.ts create mode 100644 packages/workflows-sdk/src/types/service.ts diff --git a/.changeset/pink-balloons-search.md b/.changeset/pink-balloons-search.md new file mode 100644 index 0000000000..cab0d69c78 --- /dev/null +++ b/.changeset/pink-balloons-search.md @@ -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 diff --git a/.eslintignore b/.eslintignore index d05cda3f13..2af245f62d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -24,6 +24,9 @@ packages/* !packages/orchestration !packages/workflows-sdk !packages/core-flows +!packages/workflow-engine-redis +!packages/workflow-engine-inmemory + **/models/* diff --git a/.eslintrc.js b/.eslintrc.js index 22a44eb628..caafd0d9f6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -72,9 +72,7 @@ module.exports = { node: true, jest: true, }, - ignorePatterns: [ - "packages/admin-next/dashboard/**/dist" - ], + ignorePatterns: ["packages/admin-next/dashboard/**/dist"], overrides: [ { files: ["*.ts"], @@ -101,6 +99,8 @@ module.exports = { "./packages/orchestration/tsconfig.json", "./packages/workflows-sdk/tsconfig.spec.json", "./packages/core-flows/tsconfig.spec.json", + "./packages/workflow-engine-redis/tsconfig.spec.json", + "./packages/workflow-engine-inmemory/tsconfig.spec.json", ], }, rules: { diff --git a/package.json b/package.json index 336ea9e9a3..b4a476e036 100644 --- a/package.json +++ b/package.json @@ -65,15 +65,15 @@ "scripts": { "hooks:install": "husky install", "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 .", "prettier": "prettier", "jest": "jest", - "test": "turbo run test --no-daemon", - "test:integration:packages": "turbo run test:integration --no-daemon --filter='./packages/*'", - "test:integration:api": "turbo run test:integration --no-daemon --filter=integration-tests-api", - "test:integration:plugins": "turbo run test:integration --no-daemon --filter=integration-tests-plugins", - "test:integration:repositories": "turbo run test:integration --no-daemon --filter=integration-tests-repositories", + "test": "turbo run test --concurrency=50% --no-daemon", + "test:integration:packages": "turbo run test:integration --concurrency=1 --no-daemon --filter='./packages/*'", + "test:integration:api": "turbo run test:integration --concurrency=50% --no-daemon --filter=integration-tests-api", + "test:integration:plugins": "turbo run test:integration --concurrency=50% --no-daemon --filter=integration-tests-plugins", + "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", "medusa-oas": "yarn ./packages/oas/medusa-oas-cli run medusa-oas", "release:snapshot": "changeset publish --no-git-tags --snapshot --tag snapshot", diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index 855e5ed084..da882ef408 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -1,5 +1,6 @@ import cors from "cors" import { Router } from "express" +import { parseCorsOrigins } from "medusa-core-utils" import middlewares from "../../middlewares" import analyticsConfigs from "./analytics-configs" import appRoutes from "./apps" @@ -18,16 +19,18 @@ import noteRoutes from "./notes" import notificationRoutes from "./notifications" import orderEditRoutes from "./order-edits" import orderRoutes from "./orders" +import paymentCollectionRoutes from "./payment-collections" +import paymentRoutes from "./payments" import priceListRoutes from "./price-lists" +import productCategoryRoutes from "./product-categories" import productTagRoutes from "./product-tags" import productTypesRoutes from "./product-types" -import publishableApiKeyRoutes from "./publishable-api-keys" import productRoutes from "./products" +import publishableApiKeyRoutes from "./publishable-api-keys" import regionRoutes from "./regions" import reservationRoutes from "./reservations" import returnReasonRoutes from "./return-reasons" import returnRoutes from "./returns" -import reservationRoutes from "./reservations" import salesChannelRoutes from "./sales-channels" import shippingOptionRoutes from "./shipping-options" import shippingProfileRoutes from "./shipping-profiles" @@ -38,10 +41,6 @@ import taxRateRoutes from "./tax-rates" import uploadRoutes from "./uploads" import userRoutes, { unauthenticatedUserRoutes } from "./users" 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() diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/server/index.js b/packages/medusa/src/loaders/helpers/routing/__fixtures__/server/index.js index 11f5934140..6f13e301cb 100644 --- a/packages/medusa/src/loaders/helpers/routing/__fixtures__/server/index.js +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/server/index.js @@ -3,6 +3,7 @@ import { ModulesDefinition, registerMedusaModule, } from "@medusajs/modules-sdk" +import { ContainerRegistrationKeys } from "@medusajs/utils" import { asValue, createContainer } from "awilix" import express from "express" import jwt from "jsonwebtoken" @@ -63,6 +64,7 @@ export const createServer = async (rootDir) => { return this }.bind(container) + container.register(ContainerRegistrationKeys.PG_CONNECTION, asValue({})) container.register("featureFlagRouter", asValue(featureFlagRouter)) container.register("configModule", asValue(config)) container.register({ diff --git a/packages/modules-sdk/medusajs-modules-sdk-1.12.6.tgz b/packages/modules-sdk/medusajs-modules-sdk-1.12.6.tgz new file mode 100644 index 0000000000000000000000000000000000000000..968452f2038d62c0156e180aa7807e60320380ea GIT binary patch literal 28833 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 literal 0 HcmV?d00001 diff --git a/packages/modules-sdk/src/definitions.ts b/packages/modules-sdk/src/definitions.ts index 59e4c71fed..4be77eb5e3 100644 --- a/packages/modules-sdk/src/definitions.ts +++ b/packages/modules-sdk/src/definitions.ts @@ -16,6 +16,7 @@ export enum Modules { PRICING = "pricingService", PROMOTION = "promotion", AUTHENTICATION = "authentication", + WORKFLOW_ENGINE = "workflows", CART = "cart", CUSTOMER = "customer", PAYMENT = "payment", @@ -30,6 +31,7 @@ export enum ModuleRegistrationName { PRICING = "pricingModuleService", PROMOTION = "promotionModuleService", AUTHENTICATION = "authenticationModuleService", + WORKFLOW_ENGINE = "workflowsModuleService", CART = "cartModuleService", CUSTOMER = "customerModuleService", PAYMENT = "paymentModuleService", @@ -45,6 +47,7 @@ export const MODULE_PACKAGE_NAMES = { [Modules.PRICING]: "@medusajs/pricing", [Modules.PROMOTION]: "@medusajs/promotion", [Modules.AUTHENTICATION]: "@medusajs/authentication", + [Modules.WORKFLOW_ENGINE]: "@medusajs/workflow-engine-inmemory", [Modules.CART]: "@medusajs/cart", [Modules.CUSTOMER]: "@medusajs/customer", [Modules.PAYMENT]: "@medusajs/payment", @@ -165,6 +168,20 @@ export const ModulesDefinition: { [key: string | Modules]: ModuleDefinition } = 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]: { key: Modules.CART, registrationName: ModuleRegistrationName.CART, diff --git a/packages/modules-sdk/src/medusa-app.ts b/packages/modules-sdk/src/medusa-app.ts index 4359cd5b53..fff2bf0fec 100644 --- a/packages/modules-sdk/src/medusa-app.ts +++ b/packages/modules-sdk/src/medusa-app.ts @@ -245,6 +245,12 @@ export async function MedusaApp({ registerCustomJoinerConfigs(servicesConfig ?? []) if ( + sharedResourcesConfig?.database?.connection && + !injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION] + ) { + injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION] = + sharedResourcesConfig.database.connection + } else if ( dbData.clientUrl && !injectedDependencies[ContainerRegistrationKeys.PG_CONNECTION] ) { diff --git a/packages/orchestration/src/__tests__/transaction/transaction-orchestrator.ts b/packages/orchestration/src/__tests__/transaction/transaction-orchestrator.ts index 19dcbdcfbb..cd5addce28 100644 --- a/packages/orchestration/src/__tests__/transaction/transaction-orchestrator.ts +++ b/packages/orchestration/src/__tests__/transaction/transaction-orchestrator.ts @@ -1,10 +1,14 @@ +import { TransactionStepState, TransactionStepStatus } from "@medusajs/utils" +import { setTimeout } from "timers/promises" import { DistributedTransaction, TransactionHandlerType, TransactionOrchestrator, TransactionPayload, TransactionState, + TransactionStepTimeoutError, TransactionStepsDefinition, + TransactionTimeoutError, } from "../../transaction" describe("Transaction Orchestrator", () => { @@ -986,4 +990,454 @@ describe("Transaction Orchestrator", () => { 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) + }) + }) }) diff --git a/packages/orchestration/src/transaction/datastore/abstract-storage.ts b/packages/orchestration/src/transaction/datastore/abstract-storage.ts index aab8c5e677..defc91d6cd 100644 --- a/packages/orchestration/src/transaction/datastore/abstract-storage.ts +++ b/packages/orchestration/src/transaction/datastore/abstract-storage.ts @@ -3,14 +3,11 @@ import { TransactionCheckpoint, } from "../distributed-transaction" import { TransactionStep } from "../transaction-step" -import { TransactionModelOptions } from "../types" export interface IDistributedTransactionStorage { get(key: string): Promise list(): Promise save(key: string, data: TransactionCheckpoint, ttl?: number): Promise - delete(key: string): Promise - archive(key: string, options?: TransactionModelOptions): Promise scheduleRetry( transaction: DistributedTransaction, step: TransactionStep, @@ -62,14 +59,6 @@ export abstract class DistributedTransactionStorage throw new Error("Method 'save' not implemented.") } - async delete(key: string): Promise { - throw new Error("Method 'delete' not implemented.") - } - - async archive(key: string, options?: TransactionModelOptions): Promise { - throw new Error("Method 'archive' not implemented.") - } - async scheduleRetry( transaction: DistributedTransaction, step: TransactionStep, diff --git a/packages/orchestration/src/transaction/datastore/base-in-memory-storage.ts b/packages/orchestration/src/transaction/datastore/base-in-memory-storage.ts index 69ab557a03..23ac1438cb 100644 --- a/packages/orchestration/src/transaction/datastore/base-in-memory-storage.ts +++ b/packages/orchestration/src/transaction/datastore/base-in-memory-storage.ts @@ -1,5 +1,5 @@ +import { TransactionState } from "@medusajs/utils" import { TransactionCheckpoint } from "../distributed-transaction" -import { TransactionModelOptions } from "../types" import { DistributedTransactionStorage } from "./abstract-storage" // eslint-disable-next-line max-len @@ -24,14 +24,16 @@ export class BaseInMemoryDistributedTransactionStorage extends DistributedTransa data: TransactionCheckpoint, ttl?: number ): Promise { - this.storage.set(key, data) - } + const hasFinished = [ + TransactionState.DONE, + TransactionState.REVERTED, + TransactionState.FAILED, + ].includes(data.flow.state) - async delete(key: string): Promise { - this.storage.delete(key) - } - - async archive(key: string, options?: TransactionModelOptions): Promise { - this.storage.delete(key) + if (hasFinished) { + this.storage.delete(key) + } else { + this.storage.set(key, data) + } } } diff --git a/packages/orchestration/src/transaction/distributed-transaction.ts b/packages/orchestration/src/transaction/distributed-transaction.ts index 16d05c2f62..0a9e14e787 100644 --- a/packages/orchestration/src/transaction/distributed-transaction.ts +++ b/packages/orchestration/src/transaction/distributed-transaction.ts @@ -86,7 +86,7 @@ export class DistributedTransaction extends EventEmitter { this.keyValueStore = storage } - private static keyPrefix = "dtrans" + public static keyPrefix = "dtrans" constructor( private flow: TransactionFlow, @@ -177,18 +177,18 @@ export class DistributedTransaction extends EventEmitter { } public hasTimeout(): boolean { - return !!this.getFlow().definition.timeout + return !!this.getTimeout() } - public getTimeoutInterval(): number | undefined { - return this.getFlow().definition.timeout + public getTimeout(): number | undefined { + return this.getFlow().options?.timeout } public async saveCheckpoint( ttl = 0 ): Promise { const options = this.getFlow().options - if (!options?.storeExecution) { + if (!options?.store) { return } @@ -226,31 +226,6 @@ export class DistributedTransaction extends EventEmitter { return null } - public async deleteCheckpoint(): Promise { - 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 { - const options = this.getFlow().options - - const key = TransactionOrchestrator.getKeyName( - DistributedTransaction.keyPrefix, - this.modelId, - this.transactionId - ) - await DistributedTransaction.keyValueStore.archive(key, options) - } - public async scheduleRetry( step: TransactionStep, interval: number @@ -269,6 +244,11 @@ export class DistributedTransaction extends EventEmitter { } public async scheduleTransactionTimeout(interval: number): Promise { + // schedule transaction timeout only if there are async steps + if (!this.getFlow().hasAsyncSteps) { + return + } + await this.saveCheckpoint() await DistributedTransaction.keyValueStore.scheduleTransactionTimeout( this, @@ -278,6 +258,10 @@ export class DistributedTransaction extends EventEmitter { } public async clearTransactionTimeout(): Promise { + if (!this.getFlow().hasAsyncSteps) { + return + } + await DistributedTransaction.keyValueStore.clearTransactionTimeout(this) } @@ -285,6 +269,11 @@ export class DistributedTransaction extends EventEmitter { step: TransactionStep, interval: number ): Promise { + // schedule step timeout only if the step is async + if (!step.definition.async) { + return + } + await this.saveCheckpoint() await DistributedTransaction.keyValueStore.scheduleStepTimeout( this, @@ -295,6 +284,10 @@ export class DistributedTransaction extends EventEmitter { } public async clearStepTimeout(step: TransactionStep): Promise { + if (!step.definition.async || step.isCompensating()) { + return + } + await DistributedTransaction.keyValueStore.clearStepTimeout(this, step) } } diff --git a/packages/orchestration/src/transaction/errors.ts b/packages/orchestration/src/transaction/errors.ts index 331784e80c..2bbda62dc2 100644 --- a/packages/orchestration/src/transaction/errors.ts +++ b/packages/orchestration/src/transaction/errors.ts @@ -4,7 +4,7 @@ export class PermanentStepFailureError extends Error { ): error is PermanentStepFailureError { return ( error instanceof PermanentStepFailureError || - error.name === "PermanentStepFailure" + error?.name === "PermanentStepFailure" ) } @@ -14,16 +14,19 @@ export class PermanentStepFailureError extends Error { } } -export class StepTimeoutError extends Error { - static isStepTimeoutError(error: Error): error is StepTimeoutError { +export class TransactionStepTimeoutError extends Error { + static isTransactionStepTimeoutError( + error: Error + ): error is TransactionStepTimeoutError { return ( - error instanceof StepTimeoutError || error.name === "StepTimeoutError" + error instanceof TransactionStepTimeoutError || + error?.name === "TransactionStepTimeoutError" ) } constructor(message?: string) { super(message) - this.name = "StepTimeoutError" + this.name = "TransactionStepTimeoutError" } } @@ -33,7 +36,7 @@ export class TransactionTimeoutError extends Error { ): error is TransactionTimeoutError { return ( error instanceof TransactionTimeoutError || - error.name === "TransactionTimeoutError" + error?.name === "TransactionTimeoutError" ) } diff --git a/packages/orchestration/src/transaction/orchestrator-builder.ts b/packages/orchestration/src/transaction/orchestrator-builder.ts index 711902c5d6..a645cf72fd 100644 --- a/packages/orchestration/src/transaction/orchestrator-builder.ts +++ b/packages/orchestration/src/transaction/orchestrator-builder.ts @@ -314,7 +314,7 @@ export class OrchestratorBuilder { action: string, step: InternalStep = this.steps ): InternalStep | undefined { - if (step.action === action) { + if (step.uuid === action || step.action === action) { return step } @@ -357,7 +357,7 @@ export class OrchestratorBuilder { if (!nextStep) { continue } - if (nextStep.action === action) { + if (nextStep.uuid === action || nextStep.action === action) { return step } const foundStep = this.findParentStepByAction( diff --git a/packages/orchestration/src/transaction/transaction-orchestrator.ts b/packages/orchestration/src/transaction/transaction-orchestrator.ts index 1a6de79a67..7a7fa54c2a 100644 --- a/packages/orchestration/src/transaction/transaction-orchestrator.ts +++ b/packages/orchestration/src/transaction/transaction-orchestrator.ts @@ -13,11 +13,11 @@ import { TransactionStepStatus, } from "./types" -import { MedusaError, promiseAll } from "@medusajs/utils" +import { MedusaError, promiseAll, TransactionStepState } from "@medusajs/utils" import { EventEmitter } from "events" import { PermanentStepFailureError, - StepTimeoutError, + TransactionStepTimeoutError, TransactionTimeoutError, } from "./errors" @@ -30,6 +30,7 @@ export type TransactionFlow = { hasFailedSteps: boolean hasWaitingSteps: boolean hasSkippedSteps: boolean + hasRevertedSteps: boolean timedOutAt: number | null startedAt?: number state: TransactionState @@ -62,10 +63,6 @@ export class TransactionOrchestrator extends EventEmitter { return params.join(this.SEPARATOR) } - public getOptions(): TransactionModelOptions { - return this.options ?? {} - } - private getPreviousStep(flow: TransactionFlow, step: TransactionStep) { const id = step.id.split(".") id.pop() @@ -73,6 +70,10 @@ export class TransactionOrchestrator extends EventEmitter { return flow.steps[parentId] } + public getOptions(): TransactionModelOptions { + return this.options ?? {} + } + private getInvokeSteps(flow: TransactionFlow): string[] { if (this.invokeSteps.length) { return this.invokeSteps @@ -102,9 +103,10 @@ export class TransactionOrchestrator extends EventEmitter { private canMoveForward(flow: TransactionFlow, previousStep: TransactionStep) { const states = [ - TransactionState.DONE, - TransactionState.FAILED, - TransactionState.SKIPPED, + TransactionStepState.DONE, + TransactionStepState.FAILED, + TransactionStepState.TIMEOUT, + TransactionStepState.SKIPPED, ] const siblings = this.getPreviousStep(flow, previousStep).next.map( @@ -119,10 +121,10 @@ export class TransactionOrchestrator extends EventEmitter { private canMoveBackward(flow: TransactionFlow, step: TransactionStep) { const states = [ - TransactionState.DONE, - TransactionState.REVERTED, - TransactionState.FAILED, - TransactionState.DORMANT, + TransactionStepState.DONE, + TransactionStepState.REVERTED, + TransactionStepState.FAILED, + TransactionStepState.DORMANT, ] const siblings = step.next.map((sib) => flow.steps[sib]) return ( @@ -144,29 +146,89 @@ export class TransactionOrchestrator extends EventEmitter { } } - private async checkStepTimeout(transaction, step) { + private hasExpired( + { + transaction, + step, + }: { + 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.hasTimeout() && !step.timedOutAt && step.canCancel() && - step.startedAt! + step.getTimeoutInterval()! * 1e3 < Date.now() + this.hasExpired({ step }, Date.now()) ) { step.timedOutAt = Date.now() - await transaction.saveCheckpoint() - this.emit(DistributedTransactionEvent.TIMEOUT, { transaction }) - await TransactionOrchestrator.setStepFailure( + + await TransactionOrchestrator.setStepTimeout( transaction, step, - new StepTimeoutError(), - 0 + new TransactionStepTimeoutError() ) hasTimedOut = true + + await transaction.saveCheckpoint() + + this.emit(DistributedTransactionEvent.TIMEOUT, { transaction }) } return hasTimedOut } private async checkAllSteps(transaction: DistributedTransaction): Promise<{ + current: TransactionStep[] next: TransactionStep[] total: number remaining: number @@ -182,6 +244,8 @@ export class TransactionOrchestrator extends EventEmitter { const flow = transaction.getFlow() const nextSteps: TransactionStep[] = [] + const currentSteps: TransactionStep[] = [] + const allSteps = flow.state === TransactionState.COMPENSATING ? this.getCompensationSteps(flow) @@ -204,6 +268,7 @@ export class TransactionOrchestrator extends EventEmitter { } if (curState.status === TransactionStepStatus.WAITING) { + currentSteps.push(stepDef) hasWaiting = true if (stepDef.hasAwaitingRetry()) { @@ -223,6 +288,8 @@ export class TransactionOrchestrator extends EventEmitter { continue } else if (curState.status === TransactionStepStatus.TEMPORARY_FAILURE) { + currentSteps.push(stepDef) + if (!stepDef.canRetry()) { if (stepDef.hasRetryInterval() && !stepDef.retryRescheduledAt) { stepDef.hasScheduledRetry = true @@ -243,11 +310,11 @@ export class TransactionOrchestrator extends EventEmitter { } else { completedSteps++ - if (curState.state === TransactionState.SKIPPED) { + if (curState.state === TransactionStepState.SKIPPED) { hasSkipped = true - } else if (curState.state === TransactionState.REVERTED) { + } else if (curState.state === TransactionStepState.REVERTED) { hasReverted = true - } else if (curState.state === TransactionState.FAILED) { + } else if (curState.state === TransactionStepState.FAILED) { if (stepDef.definition.continueOnPermanentFailure) { hasIgnoredFailure = true } else { @@ -258,6 +325,7 @@ export class TransactionOrchestrator extends EventEmitter { } flow.hasWaitingSteps = hasWaiting + flow.hasRevertedSteps = hasReverted const totalSteps = allSteps.length - 1 if ( @@ -288,6 +356,7 @@ export class TransactionOrchestrator extends EventEmitter { } return { + current: currentSteps, next: nextSteps, total: totalSteps, remaining: totalSteps - completedSteps, @@ -304,11 +373,13 @@ export class TransactionOrchestrator extends EventEmitter { const stepDef = flow.steps[step] const curState = stepDef.getStates() if ( - curState.state === TransactionState.DONE || + [TransactionStepState.DONE, TransactionStepState.TIMEOUT].includes( + curState.state + ) || curState.status === TransactionStepStatus.PERMANENT_FAILURE ) { stepDef.beginCompensation() - stepDef.changeState(TransactionState.NOT_STARTED) + stepDef.changeState(TransactionStepState.NOT_STARTED) } } } @@ -318,6 +389,9 @@ export class TransactionOrchestrator extends EventEmitter { step: TransactionStep, response: unknown ): Promise { + const hasStepTimedOut = + step.getStates().state === TransactionStepState.TIMEOUT + if (step.saveResponse) { transaction.addResponse( step.definition.action!, @@ -328,16 +402,19 @@ export class TransactionOrchestrator extends EventEmitter { ) } - step.changeStatus(TransactionStepStatus.OK) + const flow = transaction.getFlow() - if (step.isCompensating()) { - step.changeState(TransactionState.REVERTED) - } else { - step.changeState(TransactionState.DONE) + if (!hasStepTimedOut) { + step.changeStatus(TransactionStepStatus.OK) } - const flow = transaction.getFlow() - if (step.definition.async || flow.options?.strictCheckpoints) { + if (step.isCompensating()) { + step.changeState(TransactionStepState.REVERTED) + } else if (!hasStepTimedOut) { + step.changeState(TransactionStepState.DONE) + } + + if (step.definition.async || flow.options?.storeExecution) { await transaction.saveCheckpoint() } @@ -357,35 +434,87 @@ export class TransactionOrchestrator extends EventEmitter { transaction.emit(eventName, { step, transaction }) } + private static async setStepTimeout( + transaction: DistributedTransaction, + step: TransactionStep, + error: TransactionStepTimeoutError | TransactionTimeoutError + ): Promise { + 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( transaction: DistributedTransaction, step: TransactionStep, error: Error | any, - maxRetries: number = TransactionOrchestrator.DEFAULT_RETRIES + maxRetries: number = TransactionOrchestrator.DEFAULT_RETRIES, + isTimeout = false, + timeoutError?: TransactionStepTimeoutError | TransactionTimeoutError ): Promise { step.failures++ - step.changeStatus(TransactionStepStatus.TEMPORARY_FAILURE) + if ( + !isTimeout && + step.getStates().status !== TransactionStepStatus.PERMANENT_FAILURE + ) { + step.changeStatus(TransactionStepStatus.TEMPORARY_FAILURE) + } const flow = transaction.getFlow() const cleaningUp: Promise[] = [] - 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) - transaction.addError( - step.definition.action!, - step.isCompensating() - ? TransactionHandlerType.COMPENSATE - : TransactionHandlerType.INVOKE, - error - ) + if (!isTimeout) { + transaction.addError( + step.definition.action!, + step.isCompensating() + ? TransactionHandlerType.COMPENSATE + : TransactionHandlerType.INVOKE, + error + ) + } if (!step.isCompensating()) { - if (step.definition.continueOnPermanentFailure) { + if ( + step.definition.continueOnPermanentFailure && + !TransactionTimeoutError.isTransactionTimeoutError(timeoutError!) + ) { for (const childStep of step.next) { const child = flow.steps[childStep] - child.changeState(TransactionState.SKIPPED) + child.changeState(TransactionStepState.SKIPPED) } } else { 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() } @@ -413,33 +542,6 @@ export class TransactionOrchestrator extends EventEmitter { 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( transaction: DistributedTransaction ): Promise { @@ -456,22 +558,19 @@ export class TransactionOrchestrator extends EventEmitter { const hasTimedOut = await this.checkTransactionTimeout( transaction, - nextSteps.next + nextSteps.current ) + if (hasTimedOut) { continue } if (nextSteps.remaining === 0) { 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 }) } @@ -486,20 +585,20 @@ export class TransactionOrchestrator extends EventEmitter { step.lastAttempt = Date.now() step.attempts++ - if (curState.state === TransactionState.NOT_STARTED) { + if (curState.state === TransactionStepState.NOT_STARTED) { if (!step.startedAt) { step.startedAt = Date.now() } if (step.isCompensating()) { - step.changeState(TransactionState.COMPENSATING) + step.changeState(TransactionStepState.COMPENSATING) if (step.definition.noCompensation) { - step.changeState(TransactionState.REVERTED) + step.changeState(TransactionStepState.REVERTED) continue } } else if (flow.state === TransactionState.INVOKING) { - step.changeState(TransactionState.INVOKING) + step.changeState(TransactionStepState.INVOKING) } } @@ -554,6 +653,14 @@ export class TransactionOrchestrator extends EventEmitter { transaction .handler(step.definition.action + "", type, payload, transaction) .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( transaction, step, @@ -561,6 +668,14 @@ export class TransactionOrchestrator extends EventEmitter { ) }) .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 ( PermanentStepFailureError.isPermanentStepFailureError(error) ) { @@ -573,7 +688,7 @@ export class TransactionOrchestrator extends EventEmitter { ) } else { execution.push( - transaction.saveCheckpoint().then(async () => + transaction.saveCheckpoint().then(() => { transaction .handler( step.definition.action + "", @@ -591,12 +706,12 @@ export class TransactionOrchestrator extends EventEmitter { await setStepFailure(error) }) - ) + }) ) } } - if (hasSyncSteps && flow.options?.strictCheckpoints) { + if (hasSyncSteps && flow.options?.storeExecution) { await transaction.saveCheckpoint() } @@ -630,16 +745,14 @@ export class TransactionOrchestrator extends EventEmitter { flow.state = TransactionState.INVOKING flow.startedAt = Date.now() - if (this.options?.storeExecution) { + if (this.options?.store) { await transaction.saveCheckpoint( flow.hasAsyncSteps ? 0 : TransactionOrchestrator.DEFAULT_TTL ) } if (transaction.hasTimeout()) { - await transaction.scheduleTransactionTimeout( - transaction.getTimeoutInterval()! - ) + await transaction.scheduleTransactionTimeout(transaction.getTimeout()!) } this.emit(DistributedTransactionEvent.BEGIN, { transaction }) @@ -682,12 +795,19 @@ export class TransactionOrchestrator extends EventEmitter { this.definition ) + this.options ??= {} + const hasAsyncSteps = features.hasAsyncSteps const hasStepTimeouts = features.hasStepTimeouts const hasRetriesTimeout = features.hasRetriesTimeout + const hasTransactionTimeout = !!this.options.timeout - this.options ??= {} - if (hasAsyncSteps || hasStepTimeouts || hasRetriesTimeout) { + if (hasAsyncSteps) { + this.options.store = true + } + + if (hasStepTimeouts || hasRetriesTimeout || hasTransactionTimeout) { + this.options.store = true this.options.storeExecution = true } @@ -699,6 +819,7 @@ export class TransactionOrchestrator extends EventEmitter { hasFailedSteps: false, hasSkippedSteps: false, hasWaitingSteps: false, + hasRevertedSteps: false, timedOutAt: null, state: TransactionState.NOT_STARTED, definition: this.definition, @@ -807,15 +928,16 @@ export class TransactionOrchestrator extends EventEmitter { new TransactionStep(), existingSteps?.[id] || { id, + uuid: definitionCopy.uuid, depth: level.length - 1, definition: definitionCopy, saveResponse: definitionCopy.saveResponse ?? true, invoke: { - state: TransactionState.NOT_STARTED, + state: TransactionStepState.NOT_STARTED, status: TransactionStepStatus.IDLE, }, compensate: { - state: TransactionState.DORMANT, + state: TransactionStepState.DORMANT, status: TransactionStepStatus.IDLE, }, attempts: 0, @@ -861,11 +983,7 @@ export class TransactionOrchestrator extends EventEmitter { existingTransaction?.context ) - if ( - newTransaction && - this.options?.storeExecution && - this.options?.strictCheckpoints - ) { + if (newTransaction && this.options?.store && this.options?.storeExecution) { await transaction.saveCheckpoint( modelFlow.hasAsyncSteps ? 0 : TransactionOrchestrator.DEFAULT_TTL ) diff --git a/packages/orchestration/src/transaction/transaction-step.ts b/packages/orchestration/src/transaction/transaction-step.ts index 57b06acf31..bf20b5635a 100644 --- a/packages/orchestration/src/transaction/transaction-step.ts +++ b/packages/orchestration/src/transaction/transaction-step.ts @@ -1,4 +1,4 @@ -import { MedusaError } from "@medusajs/utils" +import { MedusaError, TransactionStepState } from "@medusajs/utils" import { DistributedTransaction, TransactionPayload, @@ -6,8 +6,8 @@ import { import { TransactionHandlerType, TransactionState, - TransactionStepsDefinition, TransactionStepStatus, + TransactionStepsDefinition, } from "./types" export type TransactionStepHandler = ( @@ -38,14 +38,15 @@ export class TransactionStep { */ private stepFailed = false id: string + uuid?: string depth: number definition: TransactionStepsDefinition invoke: { - state: TransactionState + state: TransactionStepState status: TransactionStepStatus } compensate: { - state: TransactionState + state: TransactionStepState status: TransactionStepStatus } attempts: number @@ -81,24 +82,25 @@ export class TransactionStep { return !this.stepFailed } - public changeState(toState: TransactionState) { + public changeState(toState: TransactionStepState) { const allowed = { - [TransactionState.DORMANT]: [TransactionState.NOT_STARTED], - [TransactionState.NOT_STARTED]: [ - TransactionState.INVOKING, - TransactionState.COMPENSATING, - TransactionState.FAILED, - TransactionState.SKIPPED, + [TransactionStepState.DORMANT]: [TransactionStepState.NOT_STARTED], + [TransactionStepState.NOT_STARTED]: [ + TransactionStepState.INVOKING, + TransactionStepState.COMPENSATING, + TransactionStepState.FAILED, + TransactionStepState.SKIPPED, ], - [TransactionState.INVOKING]: [ - TransactionState.FAILED, - TransactionState.DONE, + [TransactionStepState.INVOKING]: [ + TransactionStepState.FAILED, + TransactionStepState.DONE, + TransactionStepState.TIMEOUT, ], - [TransactionState.COMPENSATING]: [ - TransactionState.REVERTED, - TransactionState.FAILED, + [TransactionStepState.COMPENSATING]: [ + TransactionStepState.REVERTED, + TransactionStepState.FAILED, ], - [TransactionState.DONE]: [TransactionState.COMPENSATING], + [TransactionStepState.DONE]: [TransactionStepState.COMPENSATING], } const curState = this.getStates() @@ -155,10 +157,10 @@ export class TransactionStep { } hasTimeout(): boolean { - return !!this.definition.timeout + return !!this.getTimeout() } - getTimeoutInterval(): number | undefined { + getTimeout(): number | undefined { return this.definition.timeout } @@ -190,7 +192,7 @@ export class TransactionStep { const { status, state } = this.getStates() return ( (!this.isCompensating() && - state === TransactionState.NOT_STARTED && + state === TransactionStepState.NOT_STARTED && flowState === TransactionState.INVOKING) || status === TransactionStepStatus.TEMPORARY_FAILURE ) @@ -199,7 +201,7 @@ export class TransactionStep { canCompensate(flowState: TransactionState): boolean { return ( this.isCompensating() && - this.getStates().state === TransactionState.NOT_STARTED && + this.getStates().state === TransactionStepState.NOT_STARTED && flowState === TransactionState.COMPENSATING ) } diff --git a/packages/orchestration/src/transaction/types.ts b/packages/orchestration/src/transaction/types.ts index 16f27985f5..bd5abab5ca 100644 --- a/packages/orchestration/src/transaction/types.ts +++ b/packages/orchestration/src/transaction/types.ts @@ -1,51 +1,118 @@ import { DistributedTransaction } from "./distributed-transaction" import { TransactionStep } from "./transaction-step" +export { + TransactionHandlerType, + TransactionState, + TransactionStepStatus, +} from "@medusajs/utils" -export enum TransactionHandlerType { - INVOKE = "invoke", - COMPENSATE = "compensate", -} - +/** + * Defines the structure and behavior of a single step within a transaction workflow. + */ 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 + + /** + * 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 + + /** + * If true, no compensation action will be triggered for this step in case of a failure. + */ 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 + + /** + * The interval (in seconds) between retry attempts after a temporary failure. + * The default is to retry immediately. + */ retryInterval?: number + + /** + * The interval (in seconds) to retry a step even if its status is "TransactionStepStatus.WAITING". + */ 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 + + /** + * 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 + + /** + * 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 + + /** + * If true, the workflow will not wait for their sibling steps to complete before moving to the next step. + */ noWait?: boolean + + /** + * If true, the response of this step will be stored. + * Default is true. + */ 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[] + + // TODO: add metadata field for customizations } -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", - DORMANT = "dormant", - SKIPPED = "skipped", -} - +/** + * Defines the options for a transaction model, which are applicable to the entire workflow. + */ export type TransactionModelOptions = { + /** + * The global timeout for the entire transaction workflow (in seconds). + */ timeout?: number - storeExecution?: boolean + + /** + * If true, the state of the transaction will be persisted. + */ + store?: boolean + + /** + * TBD + */ 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 = { diff --git a/packages/orchestration/src/workflow/workflow-manager.ts b/packages/orchestration/src/workflow/workflow-manager.ts index f0f0c06dcf..62c648b523 100644 --- a/packages/orchestration/src/workflow/workflow-manager.ts +++ b/packages/orchestration/src/workflow/workflow-manager.ts @@ -81,9 +81,16 @@ export class WorkflowManager { const finalFlow = flow instanceof OrchestratorBuilder ? flow.build() : flow if (WorkflowManager.workflows.has(workflowId)) { + function excludeStepUuid(key, value) { + return key === "uuid" ? undefined : value + } + const areStepsEqual = finalFlow - ? JSON.stringify(finalFlow) === - JSON.stringify(WorkflowManager.workflows.get(workflowId)!.flow_) + ? JSON.stringify(finalFlow, excludeStepUuid) === + JSON.stringify( + WorkflowManager.workflows.get(workflowId)!.flow_, + excludeStepUuid + ) : true if (!areStepsEqual) { @@ -131,14 +138,19 @@ export class WorkflowManager { } const finalFlow = flow instanceof OrchestratorBuilder ? flow.build() : flow + const updatedOptions = { ...workflow.options, ...options } WorkflowManager.workflows.set(workflowId, { id: workflowId, flow_: finalFlow, - orchestrator: new TransactionOrchestrator(workflowId, finalFlow, options), + orchestrator: new TransactionOrchestrator( + workflowId, + finalFlow, + updatedOptions + ), handler: WorkflowManager.buildHandlers(workflow.handlers_), handlers_: workflow.handlers_, - options: { ...workflow.options, ...options }, + options: updatedOptions, requiredModules, optionalModules, }) diff --git a/packages/types/src/bundles.ts b/packages/types/src/bundles.ts index da5d2a0950..ff2518bdf5 100644 --- a/packages/types/src/bundles.ts +++ b/packages/types/src/bundles.ts @@ -18,4 +18,3 @@ export * as SearchTypes from "./search" export * as StockLocationTypes from "./stock-location" export * as TransactionBaseTypes from "./transaction-base" export * as WorkflowTypes from "./workflow" - diff --git a/packages/types/src/dal/repository-service.ts b/packages/types/src/dal/repository-service.ts index 652a3a9b02..cc18daff1a 100644 --- a/packages/types/src/dal/repository-service.ts +++ b/packages/types/src/dal/repository-service.ts @@ -46,7 +46,7 @@ export interface RepositoryService< update(data: TDTOs["update"][], context?: Context): Promise - delete(ids: string[], context?: Context): Promise + delete(idsOrPKs: string[] | object[], context?: Context): Promise /** * Soft delete entities and cascade to related entities if configured. diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 823b2eada2..c7cf134dd6 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -14,9 +14,9 @@ export * from "./joiner" export * from "./link-modules" export * from "./logger" export * from "./modules-sdk" +export * from "./payment" export * from "./pricing" export * from "./product" -export * from "./payment" export * from "./product-category" export * from "./promotion" export * from "./region" diff --git a/packages/utils/src/dal/mikro-orm/mikro-orm-create-connection.ts b/packages/utils/src/dal/mikro-orm/mikro-orm-create-connection.ts index ecfedbc5cc..7b35fa2f9f 100644 --- a/packages/utils/src/dal/mikro-orm/mikro-orm-create-connection.ts +++ b/packages/utils/src/dal/mikro-orm/mikro-orm-create-connection.ts @@ -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 driverOptions = database.connection clientUrl = - database.connection.context.client.config.connection.connectionString - schema = database.connection.context.client.config.searchPath + database.connection.context?.client?.config?.connection?.connectionString + schema = database.connection.context?.client?.config?.searchPath } const { MikroORM } = await import("@mikro-orm/postgresql") diff --git a/packages/utils/src/modules-sdk/decorators/index.ts b/packages/utils/src/modules-sdk/decorators/index.ts index ef9137051a..fbea009476 100644 --- a/packages/utils/src/modules-sdk/decorators/index.ts +++ b/packages/utils/src/modules-sdk/decorators/index.ts @@ -1,3 +1,3 @@ -export * from "./inject-transaction-manager" export * from "./inject-manager" export * from "./inject-shared-context" +export * from "./inject-transaction-manager" diff --git a/packages/utils/src/modules-sdk/load-module-database-config.ts b/packages/utils/src/modules-sdk/load-module-database-config.ts index 2f9a2ec924..b92defb173 100644 --- a/packages/utils/src/modules-sdk/load-module-database-config.ts +++ b/packages/utils/src/modules-sdk/load-module-database-config.ts @@ -93,7 +93,7 @@ export function loadDatabaseConfig( database.connection = options.database!.connection } - if (!database.clientUrl && !silent) { + if (!database.clientUrl && !silent && !database.connection) { throw new MedusaError( 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." diff --git a/packages/utils/src/orchestration/index.ts b/packages/utils/src/orchestration/index.ts index e6355e4311..98bcdbbef7 100644 --- a/packages/utils/src/orchestration/index.ts +++ b/packages/utils/src/orchestration/index.ts @@ -1 +1,2 @@ export * from "./symbol" +export * from "./types" diff --git a/packages/utils/src/orchestration/types.ts b/packages/utils/src/orchestration/types.ts new file mode 100644 index 0000000000..26cba445d6 --- /dev/null +++ b/packages/utils/src/orchestration/types.ts @@ -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", +} diff --git a/packages/workflow-engine-inmemory/.gitignore b/packages/workflow-engine-inmemory/.gitignore new file mode 100644 index 0000000000..874c6c69d3 --- /dev/null +++ b/packages/workflow-engine-inmemory/.gitignore @@ -0,0 +1,6 @@ +/dist +node_modules +.DS_store +.env* +.env +*.sql diff --git a/packages/workflow-engine-inmemory/CHANGELOG.md b/packages/workflow-engine-inmemory/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/workflow-engine-inmemory/README.md b/packages/workflow-engine-inmemory/README.md new file mode 100644 index 0000000000..b34e46ea20 --- /dev/null +++ b/packages/workflow-engine-inmemory/README.md @@ -0,0 +1 @@ +# Workflow Orchestrator diff --git a/packages/workflow-engine-inmemory/integration-tests/__fixtures__/index.ts b/packages/workflow-engine-inmemory/integration-tests/__fixtures__/index.ts new file mode 100644 index 0000000000..987a8a99bd --- /dev/null +++ b/packages/workflow-engine-inmemory/integration-tests/__fixtures__/index.ts @@ -0,0 +1,4 @@ +export * from "./workflow_1" +export * from "./workflow_2" +export * from "./workflow_step_timeout" +export * from "./workflow_transaction_timeout" diff --git a/packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_1.ts b/packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_1.ts new file mode 100644 index 0000000000..cb0056466e --- /dev/null +++ b/packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_1.ts @@ -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) +}) diff --git a/packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_2.ts b/packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_2.ts new file mode 100644 index 0000000000..f15d51889f --- /dev/null +++ b/packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_2.ts @@ -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) + } +) diff --git a/packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_step_timeout.ts b/packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_step_timeout.ts new file mode 100644 index 0000000000..a97112ffc1 --- /dev/null +++ b/packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_step_timeout.ts @@ -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 + } +) diff --git a/packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_transaction_timeout.ts b/packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_transaction_timeout.ts new file mode 100644 index 0000000000..154da2b5d4 --- /dev/null +++ b/packages/workflow-engine-inmemory/integration-tests/__fixtures__/workflow_transaction_timeout.ts @@ -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 + } +) diff --git a/packages/workflow-engine-inmemory/integration-tests/__tests__/index.spec.ts b/packages/workflow-engine-inmemory/integration-tests/__tests__/index.spec.ts new file mode 100644 index 0000000000..11b92ab0cb --- /dev/null +++ b/packages/workflow-engine-inmemory/integration-tests/__tests__/index.spec.ts @@ -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({ + 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 + ) => Promise + + 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") + }) + }) +}) diff --git a/packages/workflow-engine-inmemory/integration-tests/setup-env.js b/packages/workflow-engine-inmemory/integration-tests/setup-env.js new file mode 100644 index 0000000000..7de2d9de24 --- /dev/null +++ b/packages/workflow-engine-inmemory/integration-tests/setup-env.js @@ -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" diff --git a/packages/workflow-engine-inmemory/integration-tests/setup.js b/packages/workflow-engine-inmemory/integration-tests/setup.js new file mode 100644 index 0000000000..43f99aab4a --- /dev/null +++ b/packages/workflow-engine-inmemory/integration-tests/setup.js @@ -0,0 +1,3 @@ +import { JestUtils } from "medusa-test-utils" + +JestUtils.afterAllHookDropDatabase() diff --git a/packages/workflow-engine-inmemory/integration-tests/utils/database.ts b/packages/workflow-engine-inmemory/integration-tests/utils/database.ts new file mode 100644 index 0000000000..ed61b5e489 --- /dev/null +++ b/packages/workflow-engine-inmemory/integration-tests/utils/database.ts @@ -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 +} + +export const TestDatabase: TestDatabase = { + clearTables: async (knex) => { + await knex.raw(` + TRUNCATE TABLE workflow_execution CASCADE; + `) + }, +} diff --git a/packages/workflow-engine-inmemory/integration-tests/utils/index.ts b/packages/workflow-engine-inmemory/integration-tests/utils/index.ts new file mode 100644 index 0000000000..6b917ed30e --- /dev/null +++ b/packages/workflow-engine-inmemory/integration-tests/utils/index.ts @@ -0,0 +1 @@ +export * from "./database" diff --git a/packages/workflow-engine-inmemory/jest.config.js b/packages/workflow-engine-inmemory/jest.config.js new file mode 100644 index 0000000000..456054fe8a --- /dev/null +++ b/packages/workflow-engine-inmemory/jest.config.js @@ -0,0 +1,22 @@ +module.exports = { + moduleNameMapper: { + "^@models": "/src/models", + "^@services": "/src/services", + "^@repositories": "/src/repositories", + "^@types": "/src/types", + }, + transform: { + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsConfig: "tsconfig.spec.json", + isolatedModules: true, + }, + ], + }, + testEnvironment: `node`, + moduleFileExtensions: [`js`, `ts`], + modulePathIgnorePatterns: ["dist/"], + setupFiles: ["/integration-tests/setup-env.js"], + setupFilesAfterEnv: ["/integration-tests/setup.js"], +} diff --git a/packages/workflow-engine-inmemory/mikro-orm.config.dev.ts b/packages/workflow-engine-inmemory/mikro-orm.config.dev.ts new file mode 100644 index 0000000000..81651a7600 --- /dev/null +++ b/packages/workflow-engine-inmemory/mikro-orm.config.dev.ts @@ -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", +} diff --git a/packages/workflow-engine-inmemory/package.json b/packages/workflow-engine-inmemory/package.json new file mode 100644 index 0000000000..d82f33d8b7 --- /dev/null +++ b/packages/workflow-engine-inmemory/package.json @@ -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" + } +} diff --git a/packages/workflow-engine-inmemory/src/index.ts b/packages/workflow-engine-inmemory/src/index.ts new file mode 100644 index 0000000000..7804040565 --- /dev/null +++ b/packages/workflow-engine-inmemory/src/index.ts @@ -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" diff --git a/packages/workflow-engine-inmemory/src/initialize/index.ts b/packages/workflow-engine-inmemory/src/initialize/index.ts new file mode 100644 index 0000000000..20f4f49231 --- /dev/null +++ b/packages/workflow-engine-inmemory/src/initialize/index.ts @@ -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 => { + const loaded = + // eslint-disable-next-line max-len + await MedusaModule.bootstrap( + { + moduleKey: Modules.WORKFLOW_ENGINE, + defaultPath: MODULE_PACKAGE_NAMES[Modules.WORKFLOW_ENGINE], + declaration: options as + | InternalModuleDeclaration + | ExternalModuleDeclaration, + injectedDependencies, + moduleExports: moduleDefinition, + } + ) + + return loaded[Modules.WORKFLOW_ENGINE] +} diff --git a/packages/workflow-engine-inmemory/src/joiner-config.ts b/packages/workflow-engine-inmemory/src/joiner-config.ts new file mode 100644 index 0000000000..7999e9c3ab --- /dev/null +++ b/packages/workflow-engine-inmemory/src/joiner-config.ts @@ -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", + }, + }, +} diff --git a/packages/workflow-engine-inmemory/src/loaders/connection.ts b/packages/workflow-engine-inmemory/src/loaders/connection.ts new file mode 100644 index 0000000000..580e05e95c --- /dev/null +++ b/packages/workflow-engine-inmemory/src/loaders/connection.ts @@ -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 => { + 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, + }) +} diff --git a/packages/workflow-engine-inmemory/src/loaders/container.ts b/packages/workflow-engine-inmemory/src/loaders/container.ts new file mode 100644 index 0000000000..9a0c5553b4 --- /dev/null +++ b/packages/workflow-engine-inmemory/src/loaders/container.ts @@ -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 }, +}) diff --git a/packages/workflow-engine-inmemory/src/loaders/index.ts b/packages/workflow-engine-inmemory/src/loaders/index.ts new file mode 100644 index 0000000000..5445bc7412 --- /dev/null +++ b/packages/workflow-engine-inmemory/src/loaders/index.ts @@ -0,0 +1,3 @@ +export * from "./connection" +export * from "./container" +export * from "./utils" diff --git a/packages/workflow-engine-inmemory/src/loaders/utils.ts b/packages/workflow-engine-inmemory/src/loaders/utils.ts new file mode 100644 index 0000000000..3131eb8f92 --- /dev/null +++ b/packages/workflow-engine-inmemory/src/loaders/utils.ts @@ -0,0 +1,10 @@ +import { asClass } from "awilix" +import { InMemoryDistributedTransactionStorage } from "../utils" + +export default async ({ container }): Promise => { + container.register({ + inMemoryDistributedTransactionStorage: asClass( + InMemoryDistributedTransactionStorage + ).singleton(), + }) +} diff --git a/packages/workflow-engine-inmemory/src/migrations/Migration20231228143900.ts b/packages/workflow-engine-inmemory/src/migrations/Migration20231228143900.ts new file mode 100644 index 0000000000..af9958e80a --- /dev/null +++ b/packages/workflow-engine-inmemory/src/migrations/Migration20231228143900.ts @@ -0,0 +1,41 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20231221104256 extends Migration { + async up(): Promise { + 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 { + 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; + ` + ) + } +} diff --git a/packages/workflow-engine-inmemory/src/models/index.ts b/packages/workflow-engine-inmemory/src/models/index.ts new file mode 100644 index 0000000000..78fcbfa921 --- /dev/null +++ b/packages/workflow-engine-inmemory/src/models/index.ts @@ -0,0 +1 @@ +export { default as WorkflowExecution } from "./workflow-execution" diff --git a/packages/workflow-engine-inmemory/src/models/workflow-execution.ts b/packages/workflow-engine-inmemory/src/models/workflow-execution.ts new file mode 100644 index 0000000000..753d9e62db --- /dev/null +++ b/packages/workflow-engine-inmemory/src/models/workflow-execution.ts @@ -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 | null = null + + @Property({ columnType: "jsonb", nullable: true }) + context: Record | 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") + } +} diff --git a/packages/workflow-engine-inmemory/src/module-definition.ts b/packages/workflow-engine-inmemory/src/module-definition.ts new file mode 100644 index 0000000000..b86c23807b --- /dev/null +++ b/packages/workflow-engine-inmemory/src/module-definition.ts @@ -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, +} diff --git a/packages/workflow-engine-inmemory/src/repositories/index.ts b/packages/workflow-engine-inmemory/src/repositories/index.ts new file mode 100644 index 0000000000..8def202608 --- /dev/null +++ b/packages/workflow-engine-inmemory/src/repositories/index.ts @@ -0,0 +1,2 @@ +export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils" +export { WorkflowExecutionRepository } from "./workflow-execution" diff --git a/packages/workflow-engine-inmemory/src/repositories/workflow-execution.ts b/packages/workflow-engine-inmemory/src/repositories/workflow-execution.ts new file mode 100644 index 0000000000..9e6553ec74 --- /dev/null +++ b/packages/workflow-engine-inmemory/src/repositories/workflow-execution.ts @@ -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 +) {} diff --git a/packages/workflow-engine-inmemory/src/schema/index.ts b/packages/workflow-engine-inmemory/src/schema/index.ts new file mode 100644 index 0000000000..3d7d91edea --- /dev/null +++ b/packages/workflow-engine-inmemory/src/schema/index.ts @@ -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 +} +` diff --git a/packages/workflow-engine-inmemory/src/services/__tests__/index.spec.ts b/packages/workflow-engine-inmemory/src/services/__tests__/index.spec.ts new file mode 100644 index 0000000000..728f6245c6 --- /dev/null +++ b/packages/workflow-engine-inmemory/src/services/__tests__/index.spec.ts @@ -0,0 +1,5 @@ +describe("Noop test", () => { + it("noop check", async () => { + expect(true).toBe(true) + }) +}) diff --git a/packages/workflow-engine-inmemory/src/services/index.ts b/packages/workflow-engine-inmemory/src/services/index.ts new file mode 100644 index 0000000000..5a6d313d86 --- /dev/null +++ b/packages/workflow-engine-inmemory/src/services/index.ts @@ -0,0 +1,3 @@ +export * from "./workflow-execution" +export * from "./workflow-orchestrator" +export * from "./workflows-module" diff --git a/packages/workflow-engine-inmemory/src/services/workflow-execution.ts b/packages/workflow-engine-inmemory/src/services/workflow-execution.ts new file mode 100644 index 0000000000..158557ec0b --- /dev/null +++ b/packages/workflow-engine-inmemory/src/services/workflow-execution.ts @@ -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( + WorkflowExecution +) { + protected workflowExecutionRepository_: DAL.RepositoryService + + constructor({ workflowExecutionRepository }: InjectedDependencies) { + // @ts-ignore + super(...arguments) + this.workflowExecutionRepository_ = workflowExecutionRepository + } +} diff --git a/packages/workflow-engine-inmemory/src/services/workflow-orchestrator.ts b/packages/workflow-engine-inmemory/src/services/workflow-orchestrator.ts new file mode 100644 index 0000000000..55b2f33f15 --- /dev/null +++ b/packages/workflow-engine-inmemory/src/services/workflow-orchestrator.ts @@ -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 = FlowRunOptions & { + transactionId?: string + container?: ContainerLike +} + +type RegisterStepSuccessOptions = Omit< + WorkflowOrchestratorRunOptions, + "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 +type Subscribers = Map + +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( + workflowIdOrWorkflow: string | ReturnWorkflow, + options?: WorkflowOrchestratorRunOptions, + @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, + @MedusaContext() sharedContext: Context = {} + ): Promise { + 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( + { + idempotencyKey, + stepResponse, + options, + }: { + idempotencyKey: string | IdempotencyKeyParts + stepResponse: unknown + options?: RegisterStepSuccessOptions + }, + @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( + { + idempotencyKey, + stepResponse, + options, + }: { + idempotencyKey: string | IdempotencyKeyParts + stepResponse: unknown + options?: RegisterStepSuccessOptions + }, + @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] + } +} diff --git a/packages/workflow-engine-inmemory/src/services/workflows-module.ts b/packages/workflow-engine-inmemory/src/services/workflows-module.ts new file mode 100644 index 0000000000..31be5674d5 --- /dev/null +++ b/packages/workflow-engine-inmemory/src/services/workflows-module.ts @@ -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 = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + 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 = {}, + @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>( + workflowIdOrWorkflow: TWorkflow, + options: WorkflowOrchestratorTypes.WorkflowOrchestratorRunDTO< + TWorkflow extends ReturnWorkflow + ? UnwrapWorkflowInputDataType + : unknown + > = {}, + @MedusaContext() context: Context = {} + ) { + const ret = await this.workflowOrchestratorService_.run< + TWorkflow extends ReturnWorkflow + ? UnwrapWorkflowInputDataType + : 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 + }, + @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 + }, + @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) + } +} diff --git a/packages/workflow-engine-inmemory/src/types/index.ts b/packages/workflow-engine-inmemory/src/types/index.ts new file mode 100644 index 0000000000..0f252977b0 --- /dev/null +++ b/packages/workflow-engine-inmemory/src/types/index.ts @@ -0,0 +1,5 @@ +import { Logger } from "@medusajs/types" + +export type InitializeModuleInjectableDependencies = { + logger?: Logger +} diff --git a/packages/workflow-engine-inmemory/src/utils/index.ts b/packages/workflow-engine-inmemory/src/utils/index.ts new file mode 100644 index 0000000000..01bae8b302 --- /dev/null +++ b/packages/workflow-engine-inmemory/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./workflow-orchestrator-storage" diff --git a/packages/workflow-engine-inmemory/src/utils/workflow-orchestrator-storage.ts b/packages/workflow-engine-inmemory/src/utils/workflow-orchestrator-storage.ts new file mode 100644 index 0000000000..7254f3b90d --- /dev/null +++ b/packages/workflow-engine-inmemory/src/utils/workflow-orchestrator-storage.ts @@ -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 = new Map() + private retries: Map = new Map() + private timeouts: Map = 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 { + return this.storage.get(key) + } + + async list(): Promise { + return Array.from(this.storage.values()) + } + + async save( + key: string, + data: TransactionCheckpoint, + ttl?: number + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) + } + } +} diff --git a/packages/workflow-engine-inmemory/tsconfig.json b/packages/workflow-engine-inmemory/tsconfig.json new file mode 100644 index 0000000000..d4e5080094 --- /dev/null +++ b/packages/workflow-engine-inmemory/tsconfig.json @@ -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" + ] +} diff --git a/packages/workflow-engine-inmemory/tsconfig.spec.json b/packages/workflow-engine-inmemory/tsconfig.spec.json new file mode 100644 index 0000000000..48e47e8cbb --- /dev/null +++ b/packages/workflow-engine-inmemory/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": ["src", "integration-tests"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "sourceMap": true + } +} diff --git a/packages/workflow-engine-redis/.gitignore b/packages/workflow-engine-redis/.gitignore new file mode 100644 index 0000000000..874c6c69d3 --- /dev/null +++ b/packages/workflow-engine-redis/.gitignore @@ -0,0 +1,6 @@ +/dist +node_modules +.DS_store +.env* +.env +*.sql diff --git a/packages/workflow-engine-redis/CHANGELOG.md b/packages/workflow-engine-redis/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/workflow-engine-redis/README.md b/packages/workflow-engine-redis/README.md new file mode 100644 index 0000000000..b34e46ea20 --- /dev/null +++ b/packages/workflow-engine-redis/README.md @@ -0,0 +1 @@ +# Workflow Orchestrator diff --git a/packages/workflow-engine-redis/integration-tests/__fixtures__/index.ts b/packages/workflow-engine-redis/integration-tests/__fixtures__/index.ts new file mode 100644 index 0000000000..987a8a99bd --- /dev/null +++ b/packages/workflow-engine-redis/integration-tests/__fixtures__/index.ts @@ -0,0 +1,4 @@ +export * from "./workflow_1" +export * from "./workflow_2" +export * from "./workflow_step_timeout" +export * from "./workflow_transaction_timeout" diff --git a/packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_1.ts b/packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_1.ts new file mode 100644 index 0000000000..cb0056466e --- /dev/null +++ b/packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_1.ts @@ -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) +}) diff --git a/packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_2.ts b/packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_2.ts new file mode 100644 index 0000000000..f15d51889f --- /dev/null +++ b/packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_2.ts @@ -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) + } +) diff --git a/packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_step_timeout.ts b/packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_step_timeout.ts new file mode 100644 index 0000000000..0bdbf9fd9c --- /dev/null +++ b/packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_step_timeout.ts @@ -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 + } +) diff --git a/packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_transaction_timeout.ts b/packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_transaction_timeout.ts new file mode 100644 index 0000000000..6e1c2852f2 --- /dev/null +++ b/packages/workflow-engine-redis/integration-tests/__fixtures__/workflow_transaction_timeout.ts @@ -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 + } +) diff --git a/packages/workflow-engine-redis/integration-tests/__tests__/index.spec.ts b/packages/workflow-engine-redis/integration-tests/__tests__/index.spec.ts new file mode 100644 index 0000000000..802fff3418 --- /dev/null +++ b/packages/workflow-engine-redis/integration-tests/__tests__/index.spec.ts @@ -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({ + 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 + ) => Promise + + 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) + }) + }) +}) diff --git a/packages/workflow-engine-redis/integration-tests/setup-env.js b/packages/workflow-engine-redis/integration-tests/setup-env.js new file mode 100644 index 0000000000..18f30b372c --- /dev/null +++ b/packages/workflow-engine-redis/integration-tests/setup-env.js @@ -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" diff --git a/packages/workflow-engine-redis/integration-tests/setup.js b/packages/workflow-engine-redis/integration-tests/setup.js new file mode 100644 index 0000000000..43f99aab4a --- /dev/null +++ b/packages/workflow-engine-redis/integration-tests/setup.js @@ -0,0 +1,3 @@ +import { JestUtils } from "medusa-test-utils" + +JestUtils.afterAllHookDropDatabase() diff --git a/packages/workflow-engine-redis/integration-tests/utils/database.ts b/packages/workflow-engine-redis/integration-tests/utils/database.ts new file mode 100644 index 0000000000..582baee15c --- /dev/null +++ b/packages/workflow-engine-redis/integration-tests/utils/database.ts @@ -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 +} + +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) + } +} diff --git a/packages/workflow-engine-redis/integration-tests/utils/index.ts b/packages/workflow-engine-redis/integration-tests/utils/index.ts new file mode 100644 index 0000000000..6b917ed30e --- /dev/null +++ b/packages/workflow-engine-redis/integration-tests/utils/index.ts @@ -0,0 +1 @@ +export * from "./database" diff --git a/packages/workflow-engine-redis/jest.config.js b/packages/workflow-engine-redis/jest.config.js new file mode 100644 index 0000000000..860ba90a49 --- /dev/null +++ b/packages/workflow-engine-redis/jest.config.js @@ -0,0 +1,21 @@ +module.exports = { + moduleNameMapper: { + "^@models": "/src/models", + "^@services": "/src/services", + "^@repositories": "/src/repositories", + }, + transform: { + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsConfig: "tsconfig.spec.json", + isolatedModules: true, + }, + ], + }, + testEnvironment: `node`, + moduleFileExtensions: [`js`, `ts`], + modulePathIgnorePatterns: ["dist/"], + setupFiles: ["/integration-tests/setup-env.js"], + setupFilesAfterEnv: ["/integration-tests/setup.js"], +} diff --git a/packages/workflow-engine-redis/mikro-orm.config.dev.ts b/packages/workflow-engine-redis/mikro-orm.config.dev.ts new file mode 100644 index 0000000000..5468c7a41d --- /dev/null +++ b/packages/workflow-engine-redis/mikro-orm.config.dev.ts @@ -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", +} diff --git a/packages/workflow-engine-redis/package.json b/packages/workflow-engine-redis/package.json new file mode 100644 index 0000000000..2e8631f9c3 --- /dev/null +++ b/packages/workflow-engine-redis/package.json @@ -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" + } +} diff --git a/packages/workflow-engine-redis/src/index.ts b/packages/workflow-engine-redis/src/index.ts new file mode 100644 index 0000000000..7804040565 --- /dev/null +++ b/packages/workflow-engine-redis/src/index.ts @@ -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" diff --git a/packages/workflow-engine-redis/src/initialize/index.ts b/packages/workflow-engine-redis/src/initialize/index.ts new file mode 100644 index 0000000000..20f4f49231 --- /dev/null +++ b/packages/workflow-engine-redis/src/initialize/index.ts @@ -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 => { + const loaded = + // eslint-disable-next-line max-len + await MedusaModule.bootstrap( + { + moduleKey: Modules.WORKFLOW_ENGINE, + defaultPath: MODULE_PACKAGE_NAMES[Modules.WORKFLOW_ENGINE], + declaration: options as + | InternalModuleDeclaration + | ExternalModuleDeclaration, + injectedDependencies, + moduleExports: moduleDefinition, + } + ) + + return loaded[Modules.WORKFLOW_ENGINE] +} diff --git a/packages/workflow-engine-redis/src/joiner-config.ts b/packages/workflow-engine-redis/src/joiner-config.ts new file mode 100644 index 0000000000..7999e9c3ab --- /dev/null +++ b/packages/workflow-engine-redis/src/joiner-config.ts @@ -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", + }, + }, +} diff --git a/packages/workflow-engine-redis/src/loaders/connection.ts b/packages/workflow-engine-redis/src/loaders/connection.ts new file mode 100644 index 0000000000..580e05e95c --- /dev/null +++ b/packages/workflow-engine-redis/src/loaders/connection.ts @@ -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 => { + 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, + }) +} diff --git a/packages/workflow-engine-redis/src/loaders/container.ts b/packages/workflow-engine-redis/src/loaders/container.ts new file mode 100644 index 0000000000..9a0c5553b4 --- /dev/null +++ b/packages/workflow-engine-redis/src/loaders/container.ts @@ -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 }, +}) diff --git a/packages/workflow-engine-redis/src/loaders/index.ts b/packages/workflow-engine-redis/src/loaders/index.ts new file mode 100644 index 0000000000..8b66bc0be4 --- /dev/null +++ b/packages/workflow-engine-redis/src/loaders/index.ts @@ -0,0 +1,4 @@ +export * from "./connection" +export * from "./container" +export * from "./redis" +export * from "./utils" diff --git a/packages/workflow-engine-redis/src/loaders/redis.ts b/packages/workflow-engine-redis/src/loaders/redis.ts new file mode 100644 index 0000000000..8321a6d147 --- /dev/null +++ b/packages/workflow-engine-redis/src/loaders/redis.ts @@ -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 => { + 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 +} diff --git a/packages/workflow-engine-redis/src/loaders/utils.ts b/packages/workflow-engine-redis/src/loaders/utils.ts new file mode 100644 index 0000000000..f662dc1e17 --- /dev/null +++ b/packages/workflow-engine-redis/src/loaders/utils.ts @@ -0,0 +1,10 @@ +import { asClass } from "awilix" +import { RedisDistributedTransactionStorage } from "../utils" + +export default async ({ container }): Promise => { + container.register({ + redisDistributedTransactionStorage: asClass( + RedisDistributedTransactionStorage + ).singleton(), + }) +} diff --git a/packages/workflow-engine-redis/src/migrations/Migration20231228143900.ts b/packages/workflow-engine-redis/src/migrations/Migration20231228143900.ts new file mode 100644 index 0000000000..af9958e80a --- /dev/null +++ b/packages/workflow-engine-redis/src/migrations/Migration20231228143900.ts @@ -0,0 +1,41 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20231221104256 extends Migration { + async up(): Promise { + 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 { + 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; + ` + ) + } +} diff --git a/packages/workflow-engine-redis/src/models/index.ts b/packages/workflow-engine-redis/src/models/index.ts new file mode 100644 index 0000000000..78fcbfa921 --- /dev/null +++ b/packages/workflow-engine-redis/src/models/index.ts @@ -0,0 +1 @@ +export { default as WorkflowExecution } from "./workflow-execution" diff --git a/packages/workflow-engine-redis/src/models/workflow-execution.ts b/packages/workflow-engine-redis/src/models/workflow-execution.ts new file mode 100644 index 0000000000..753d9e62db --- /dev/null +++ b/packages/workflow-engine-redis/src/models/workflow-execution.ts @@ -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 | null = null + + @Property({ columnType: "jsonb", nullable: true }) + context: Record | 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") + } +} diff --git a/packages/workflow-engine-redis/src/module-definition.ts b/packages/workflow-engine-redis/src/module-definition.ts new file mode 100644 index 0000000000..0a3d33f580 --- /dev/null +++ b/packages/workflow-engine-redis/src/module-definition.ts @@ -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, +} diff --git a/packages/workflow-engine-redis/src/repositories/index.ts b/packages/workflow-engine-redis/src/repositories/index.ts new file mode 100644 index 0000000000..8def202608 --- /dev/null +++ b/packages/workflow-engine-redis/src/repositories/index.ts @@ -0,0 +1,2 @@ +export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils" +export { WorkflowExecutionRepository } from "./workflow-execution" diff --git a/packages/workflow-engine-redis/src/repositories/workflow-execution.ts b/packages/workflow-engine-redis/src/repositories/workflow-execution.ts new file mode 100644 index 0000000000..9e6553ec74 --- /dev/null +++ b/packages/workflow-engine-redis/src/repositories/workflow-execution.ts @@ -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 +) {} diff --git a/packages/workflow-engine-redis/src/schema/index.ts b/packages/workflow-engine-redis/src/schema/index.ts new file mode 100644 index 0000000000..3d7d91edea --- /dev/null +++ b/packages/workflow-engine-redis/src/schema/index.ts @@ -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 +} +` diff --git a/packages/workflow-engine-redis/src/services/__tests__/index.spec.ts b/packages/workflow-engine-redis/src/services/__tests__/index.spec.ts new file mode 100644 index 0000000000..728f6245c6 --- /dev/null +++ b/packages/workflow-engine-redis/src/services/__tests__/index.spec.ts @@ -0,0 +1,5 @@ +describe("Noop test", () => { + it("noop check", async () => { + expect(true).toBe(true) + }) +}) diff --git a/packages/workflow-engine-redis/src/services/index.ts b/packages/workflow-engine-redis/src/services/index.ts new file mode 100644 index 0000000000..5a6d313d86 --- /dev/null +++ b/packages/workflow-engine-redis/src/services/index.ts @@ -0,0 +1,3 @@ +export * from "./workflow-execution" +export * from "./workflow-orchestrator" +export * from "./workflows-module" diff --git a/packages/workflow-engine-redis/src/services/workflow-execution.ts b/packages/workflow-engine-redis/src/services/workflow-execution.ts new file mode 100644 index 0000000000..158557ec0b --- /dev/null +++ b/packages/workflow-engine-redis/src/services/workflow-execution.ts @@ -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( + WorkflowExecution +) { + protected workflowExecutionRepository_: DAL.RepositoryService + + constructor({ workflowExecutionRepository }: InjectedDependencies) { + // @ts-ignore + super(...arguments) + this.workflowExecutionRepository_ = workflowExecutionRepository + } +} diff --git a/packages/workflow-engine-redis/src/services/workflow-orchestrator.ts b/packages/workflow-engine-redis/src/services/workflow-orchestrator.ts new file mode 100644 index 0000000000..77770a5c74 --- /dev/null +++ b/packages/workflow-engine-redis/src/services/workflow-orchestrator.ts @@ -0,0 +1,577 @@ +import { + DistributedTransaction, + DistributedTransactionEvents, + TransactionHandlerType, + TransactionStep, +} from "@medusajs/orchestration" +import { ContainerLike, Context, MedusaContainer } from "@medusajs/types" +import { InjectSharedContext, MedusaContext, isString } from "@medusajs/utils" +import { + FlowRunOptions, + MedusaWorkflow, + ReturnWorkflow, +} from "@medusajs/workflows-sdk" +import Redis from "ioredis" +import { ulid } from "ulid" +import type { RedisDistributedTransactionStorage } from "../utils" + +export type WorkflowOrchestratorRunOptions = FlowRunOptions & { + transactionId?: string + container?: ContainerLike +} + +type RegisterStepSuccessOptions = Omit< + WorkflowOrchestratorRunOptions, + "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 +type Subscribers = Map + +const AnySubscriber = "any" + +export class WorkflowOrchestratorService { + private instanceId = ulid() + protected redisPublisher: Redis + protected redisSubscriber: Redis + private subscribers: Subscribers = new Map() + + constructor({ + redisDistributedTransactionStorage, + redisPublisher, + redisSubscriber, + }: { + redisDistributedTransactionStorage: RedisDistributedTransactionStorage + workflowOrchestratorService: WorkflowOrchestratorService + redisPublisher: Redis + redisSubscriber: Redis + }) { + this.redisPublisher = redisPublisher + this.redisSubscriber = redisSubscriber + + redisDistributedTransactionStorage.setWorkflowOrchestratorService(this) + DistributedTransaction.setStorage(redisDistributedTransactionStorage) + + this.redisSubscriber.on("message", async (_, message) => { + const { instanceId, data } = JSON.parse(message) + + await this.notify(data, false, instanceId) + }) + } + + @InjectSharedContext() + async run( + workflowIdOrWorkflow: string | ReturnWorkflow, + options?: WorkflowOrchestratorRunOptions, + @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 + await this.notify({ + eventType: "onFinish", + workflowId, + transactionId: context.transactionId, + result, + errors, + }) + } + + return { acknowledgement, ...ret } + } + + @InjectSharedContext() + async getRunningTransaction( + workflowId: string, + transactionId: string, + options?: WorkflowOrchestratorRunOptions, + @MedusaContext() sharedContext: Context = {} + ): Promise { + 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( + { + idempotencyKey, + stepResponse, + options, + }: { + idempotencyKey: string | IdempotencyKeyParts + stepResponse: unknown + options?: RegisterStepSuccessOptions + }, + @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 + await this.notify({ + eventType: "onFinish", + workflowId, + transactionId, + result, + errors, + }) + } + + return ret + } + + @InjectSharedContext() + async setStepFailure( + { + idempotencyKey, + stepResponse, + options, + }: { + idempotencyKey: string | IdempotencyKeyParts + stepResponse: unknown + options?: RegisterStepSuccessOptions + }, + @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 + await 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() + + // Subscribe instance to redis + if (!this.subscribers.has(workflowId)) { + void this.redisSubscriber.subscribe(this.getChannelName(workflowId)) + } + + 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) + }) + } + + // Unsubscribe instance + if (!this.subscribers.has(workflowId)) { + void this.redisSubscriber.unsubscribe(this.getChannelName(workflowId)) + } + + 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 async notify( + options: NotifyOptions, + publish = true, + instanceId = this.instanceId + ) { + if (!publish && instanceId === this.instanceId) { + return + } + + if (publish) { + const channel = this.getChannelName(options.workflowId) + + const message = JSON.stringify({ + instanceId: this.instanceId, + data: options, + }) + await this.redisPublisher.publish(channel, message) + } + + 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 getChannelName(workflowId: string): string { + return `orchestrator:${workflowId}` + } + + private buildWorkflowEvents({ + customEventHandlers, + workflowId, + transactionId, + }): DistributedTransactionEvents { + const notify = async ({ + eventType, + step, + result, + response, + errors, + }: { + eventType: keyof DistributedTransactionEvents + step?: TransactionStep + response?: unknown + result?: unknown + errors?: unknown[] + }) => { + await this.notify({ + workflowId, + transactionId, + eventType, + response, + step, + result, + errors, + }) + } + + return { + onTimeout: async ({ transaction }) => { + customEventHandlers?.onTimeout?.({ transaction }) + await notify({ eventType: "onTimeout" }) + }, + + onBegin: async ({ transaction }) => { + customEventHandlers?.onBegin?.({ transaction }) + await notify({ eventType: "onBegin" }) + }, + onResume: async ({ transaction }) => { + customEventHandlers?.onResume?.({ transaction }) + await notify({ eventType: "onResume" }) + }, + onCompensateBegin: async ({ transaction }) => { + customEventHandlers?.onCompensateBegin?.({ transaction }) + await notify({ eventType: "onCompensateBegin" }) + }, + onFinish: async ({ transaction, result, errors }) => { + // TODO: unsubscribe transaction handlers on finish + customEventHandlers?.onFinish?.({ transaction, result, errors }) + }, + + onStepBegin: async ({ step, transaction }) => { + customEventHandlers?.onStepBegin?.({ step, transaction }) + + await notify({ eventType: "onStepBegin", step }) + }, + onStepSuccess: async ({ step, transaction }) => { + const response = transaction.getContext().invoke[step.id] + customEventHandlers?.onStepSuccess?.({ step, transaction, response }) + + await notify({ eventType: "onStepSuccess", step, response }) + }, + onStepFailure: async ({ step, transaction }) => { + const errors = transaction.getErrors(TransactionHandlerType.INVOKE)[ + step.id + ] + customEventHandlers?.onStepFailure?.({ step, transaction, errors }) + + await notify({ eventType: "onStepFailure", step, errors }) + }, + + onCompensateStepSuccess: async ({ step, transaction }) => { + const response = transaction.getContext().compensate[step.id] + customEventHandlers?.onStepSuccess?.({ step, transaction, response }) + + await notify({ eventType: "onCompensateStepSuccess", step, response }) + }, + onCompensateStepFailure: async ({ step, transaction }) => { + const errors = transaction.getErrors(TransactionHandlerType.COMPENSATE)[ + step.id + ] + customEventHandlers?.onStepFailure?.({ step, transaction, errors }) + + await 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] + } +} diff --git a/packages/workflow-engine-redis/src/services/workflows-module.ts b/packages/workflow-engine-redis/src/services/workflows-module.ts new file mode 100644 index 0000000000..31be5674d5 --- /dev/null +++ b/packages/workflow-engine-redis/src/services/workflows-module.ts @@ -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 = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + 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 = {}, + @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>( + workflowIdOrWorkflow: TWorkflow, + options: WorkflowOrchestratorTypes.WorkflowOrchestratorRunDTO< + TWorkflow extends ReturnWorkflow + ? UnwrapWorkflowInputDataType + : unknown + > = {}, + @MedusaContext() context: Context = {} + ) { + const ret = await this.workflowOrchestratorService_.run< + TWorkflow extends ReturnWorkflow + ? UnwrapWorkflowInputDataType + : 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 + }, + @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 + }, + @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) + } +} diff --git a/packages/workflow-engine-redis/src/types/index.ts b/packages/workflow-engine-redis/src/types/index.ts new file mode 100644 index 0000000000..1b066ce1d8 --- /dev/null +++ b/packages/workflow-engine-redis/src/types/index.ts @@ -0,0 +1,34 @@ +import { Logger } from "@medusajs/types" +import { RedisOptions } from "ioredis" + +export type InitializeModuleInjectableDependencies = { + logger?: Logger +} + +/** + * Module config type + */ +export type RedisWorkflowsOptions = { + /** + * Redis connection string + */ + url?: string + + /** + * Queue name used for retries and timeouts + */ + queueName?: string + + /** + * Redis client options + */ + options?: RedisOptions + + /** + * Optiona connection string and options to pub/sub + */ + pubsub?: { + url: string + options?: RedisOptions + } +} diff --git a/packages/workflow-engine-redis/src/utils/index.ts b/packages/workflow-engine-redis/src/utils/index.ts new file mode 100644 index 0000000000..01bae8b302 --- /dev/null +++ b/packages/workflow-engine-redis/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./workflow-orchestrator-storage" diff --git a/packages/workflow-engine-redis/src/utils/workflow-orchestrator-storage.ts b/packages/workflow-engine-redis/src/utils/workflow-orchestrator-storage.ts new file mode 100644 index 0000000000..533181cf7f --- /dev/null +++ b/packages/workflow-engine-redis/src/utils/workflow-orchestrator-storage.ts @@ -0,0 +1,304 @@ +import { + DistributedTransaction, + DistributedTransactionStorage, + TransactionCheckpoint, + TransactionStep, +} from "@medusajs/orchestration" +import { TransactionState } from "@medusajs/utils" +import { + WorkflowExecutionService, + WorkflowOrchestratorService, +} from "@services" +import { Queue, Worker } from "bullmq" +import Redis from "ioredis" + +enum JobType { + RETRY = "retry", + STEP_TIMEOUT = "step_timeout", + TRANSACTION_TIMEOUT = "transaction_timeout", +} + +// eslint-disable-next-line max-len +export class RedisDistributedTransactionStorage extends DistributedTransactionStorage { + private static TTL_AFTER_COMPLETED = 60 * 15 // 15 minutes + private workflowExecutionService_: WorkflowExecutionService + private workflowOrchestratorService_: WorkflowOrchestratorService + + private redisClient: Redis + private queue: Queue + private worker: Worker + + constructor({ + workflowExecutionService, + redisConnection, + redisWorkerConnection, + redisQueueName, + }: { + workflowExecutionService: WorkflowExecutionService + redisConnection: Redis + redisWorkerConnection: Redis + redisQueueName: string + }) { + super() + + this.workflowExecutionService_ = workflowExecutionService + + this.redisClient = redisConnection + + this.queue = new Queue(redisQueueName, { connection: this.redisClient }) + this.worker = new Worker( + redisQueueName, + async (job) => { + const allJobs = [ + JobType.RETRY, + JobType.STEP_TIMEOUT, + JobType.TRANSACTION_TIMEOUT, + ] + + if (allJobs.includes(job.name as JobType)) { + await this.executeTransaction( + job.data.workflowId, + job.data.transactionId + ) + } + }, + { connection: redisWorkerConnection } + ) + } + + 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, + }, + ]) + } + + private async executeTransaction(workflowId: string, transactionId: string) { + return await this.workflowOrchestratorService_.run(workflowId, { + transactionId, + throwOnError: false, + }) + } + + private stringifyWithSymbol(key, value) { + if (key === "__type" && typeof value === "symbol") { + return Symbol.keyFor(value) + } + + return value + } + + private jsonWithSymbol(key, value) { + if (key === "__type" && typeof value === "string") { + return Symbol.for(value) + } + + return value + } + + async get(key: string): Promise { + const data = await this.redisClient.get(key) + + return data ? JSON.parse(data, this.jsonWithSymbol) : undefined + } + + async list(): Promise { + const keys = await this.redisClient.keys( + DistributedTransaction.keyPrefix + ":*" + ) + const transactions: any[] = [] + for (const key of keys) { + const data = await this.redisClient.get(key) + if (data) { + transactions.push(JSON.parse(data, this.jsonWithSymbol)) + } + } + return transactions + } + + async save( + key: string, + data: TransactionCheckpoint, + ttl?: number + ): Promise { + 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) { + if (ttl) { + await this.redisClient.set( + key, + JSON.stringify(data, this.stringifyWithSymbol), + "EX", + ttl + ) + } else { + await this.redisClient.set( + key, + JSON.stringify(data, this.stringifyWithSymbol) + ) + } + } + + if (hasFinished && !retentionTime) { + await this.deleteFromDb(data) + } else { + await this.saveToDb(data) + } + + if (hasFinished) { + // await this.redisClient.del(key) + await this.redisClient.set( + key, + JSON.stringify(data, this.stringifyWithSymbol), + "EX", + RedisDistributedTransactionStorage.TTL_AFTER_COMPLETED + ) + } + } + + async scheduleRetry( + transaction: DistributedTransaction, + step: TransactionStep, + timestamp: number, + interval: number + ): Promise { + await this.queue.add( + JobType.RETRY, + { + workflowId: transaction.modelId, + transactionId: transaction.transactionId, + stepId: step.id, + }, + { + delay: interval * 1000, + jobId: this.getJobId(JobType.RETRY, transaction, step), + removeOnComplete: true, + } + ) + } + + async clearRetry( + transaction: DistributedTransaction, + step: TransactionStep + ): Promise { + await this.removeJob(JobType.RETRY, transaction, step) + } + + async scheduleTransactionTimeout( + transaction: DistributedTransaction, + timestamp: number, + interval: number + ): Promise { + await this.queue.add( + JobType.TRANSACTION_TIMEOUT, + { + workflowId: transaction.modelId, + transactionId: transaction.transactionId, + }, + { + delay: interval * 1000, + jobId: this.getJobId(JobType.TRANSACTION_TIMEOUT, transaction), + removeOnComplete: true, + } + ) + } + + async clearTransactionTimeout( + transaction: DistributedTransaction + ): Promise { + await this.removeJob(JobType.TRANSACTION_TIMEOUT, transaction) + } + + async scheduleStepTimeout( + transaction: DistributedTransaction, + step: TransactionStep, + timestamp: number, + interval: number + ): Promise { + await this.queue.add( + JobType.STEP_TIMEOUT, + { + workflowId: transaction.modelId, + transactionId: transaction.transactionId, + stepId: step.id, + }, + { + delay: interval * 1000, + jobId: this.getJobId(JobType.STEP_TIMEOUT, transaction, step), + removeOnComplete: true, + } + ) + } + + async clearStepTimeout( + transaction: DistributedTransaction, + step: TransactionStep + ): Promise { + await this.removeJob(JobType.STEP_TIMEOUT, transaction, step) + } + + private getJobId( + type: JobType, + transaction: DistributedTransaction, + step?: TransactionStep + ) { + const key = [type, transaction.modelId, transaction.transactionId] + + if (step) { + key.push(step.id) + } + + return key.join(":") + } + + private async removeJob( + type: JobType, + transaction: DistributedTransaction, + step?: TransactionStep + ) { + const jobId = this.getJobId(type, transaction, step) + const job = await this.queue.getJob(jobId) + + if (job && job.attemptsStarted === 0) { + await job.remove() + } + } +} diff --git a/packages/workflow-engine-redis/tsconfig.json b/packages/workflow-engine-redis/tsconfig.json new file mode 100644 index 0000000000..d4e5080094 --- /dev/null +++ b/packages/workflow-engine-redis/tsconfig.json @@ -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" + ] +} diff --git a/packages/workflow-engine-redis/tsconfig.spec.json b/packages/workflow-engine-redis/tsconfig.spec.json new file mode 100644 index 0000000000..48e47e8cbb --- /dev/null +++ b/packages/workflow-engine-redis/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": ["src", "integration-tests"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "sourceMap": true + } +} diff --git a/integration-tests/plugins/__tests__/workflows/utils/composer/compose.ts b/packages/workflows-sdk/src/helper/__tests__/compose.ts similarity index 99% rename from integration-tests/plugins/__tests__/workflows/utils/composer/compose.ts rename to packages/workflows-sdk/src/helper/__tests__/compose.ts index 9fdedc22c2..b0c594596b 100644 --- a/integration-tests/plugins/__tests__/workflows/utils/composer/compose.ts +++ b/packages/workflows-sdk/src/helper/__tests__/compose.ts @@ -6,7 +6,7 @@ import { parallelize, StepResponse, transform, -} from "@medusajs/workflows-sdk" +} from "../.." jest.setTimeout(30000) diff --git a/packages/workflows-sdk/src/index.ts b/packages/workflows-sdk/src/index.ts index 9c27d4e26a..cec02f1e03 100644 --- a/packages/workflows-sdk/src/index.ts +++ b/packages/workflows-sdk/src/index.ts @@ -1,4 +1,5 @@ export * from "./helper" export * from "./medusa-workflow" +export * as WorkflowOrchestratorTypes from "./types" export * from "./utils/composer" export * as Composer from "./utils/composer" diff --git a/packages/workflows-sdk/src/types/common.ts b/packages/workflows-sdk/src/types/common.ts new file mode 100644 index 0000000000..f3a81e7271 --- /dev/null +++ b/packages/workflows-sdk/src/types/common.ts @@ -0,0 +1,21 @@ +import { BaseFilterable } from "@medusajs/types" + +export interface WorkflowExecutionDTO { + id: string + workflow_id: string + transaction_id: string + execution: string + context: string + state: any + created_at: Date + updated_at: Date + deleted_at: Date +} + +export interface FilterableWorkflowExecutionProps + extends BaseFilterable { + id?: string[] + workflow_id?: string[] + transaction_id?: string[] + state?: any[] +} diff --git a/packages/workflows-sdk/src/types/index.ts b/packages/workflows-sdk/src/types/index.ts new file mode 100644 index 0000000000..0c73656566 --- /dev/null +++ b/packages/workflows-sdk/src/types/index.ts @@ -0,0 +1,3 @@ +export * from "./common" +export * from "./mutations" +export * from "./service" diff --git a/packages/workflows-sdk/src/types/mutations.ts b/packages/workflows-sdk/src/types/mutations.ts new file mode 100644 index 0000000000..ef3234143e --- /dev/null +++ b/packages/workflows-sdk/src/types/mutations.ts @@ -0,0 +1,7 @@ +export interface UpsertWorkflowExecutionDTO { + workflow_id: string + transaction_id: string + execution: Record + context: Record + state: any +} diff --git a/packages/workflows-sdk/src/types/service.ts b/packages/workflows-sdk/src/types/service.ts new file mode 100644 index 0000000000..ed055e39e6 --- /dev/null +++ b/packages/workflows-sdk/src/types/service.ts @@ -0,0 +1,116 @@ +import { + ContainerLike, + Context, + FindConfig, + IModuleService, +} from "@medusajs/types" +import { ReturnWorkflow, UnwrapWorkflowInputDataType } from "../utils/composer" +import { + FilterableWorkflowExecutionProps, + WorkflowExecutionDTO, +} from "./common" + +type FlowRunOptions = { + input?: TData + context?: Context + resultFrom?: string | string[] | Symbol + throwOnError?: boolean + events?: Record +} + +export interface WorkflowOrchestratorRunDTO + extends FlowRunOptions { + transactionId?: string + container?: ContainerLike +} + +export type IdempotencyKeyParts = { + workflowId: string + transactionId: string + stepId: string + action: "invoke" | "compensate" +} + +export interface IWorkflowsModuleService extends IModuleService { + listWorkflowExecution( + filters?: FilterableWorkflowExecutionProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listAndCountWorkflowExecution( + filters?: FilterableWorkflowExecutionProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[WorkflowExecutionDTO[], number]> + + run< + TWorkflow extends ReturnWorkflow = ReturnWorkflow< + any, + any, + any + >, + TData = UnwrapWorkflowInputDataType + >( + workflowId: string, + options?: WorkflowOrchestratorRunDTO, + sharedContext?: Context + ): Promise<{ + errors: Error[] + transaction: object + result: any + acknowledgement: object + }> + + getRunningTransaction( + workflowId: string, + transactionId: string, + options?: Record, + sharedContext?: Context + ): Promise + + setStepSuccess( + { + idempotencyKey, + stepResponse, + options, + }: { + idempotencyKey: string | IdempotencyKeyParts + stepResponse: unknown + options?: Record + }, + sharedContext?: Context + ) + + setStepFailure( + { + idempotencyKey, + stepResponse, + options, + }: { + idempotencyKey: string | object + stepResponse: unknown + options?: Record + }, + sharedContext?: Context + ) + + subscribe( + args: { + workflowId: string + transactionId?: string + subscriber: Function + subscriberId?: string + }, + sharedContext?: Context + ): Promise + + unsubscribe( + args: { + workflowId: string + transactionId?: string + subscriberOrId: string | Function + }, + sharedContext?: Context + ) +} diff --git a/packages/workflows-sdk/src/utils/_playground.ts b/packages/workflows-sdk/src/utils/_playground.ts index 6bc33ea235..8224af5046 100644 --- a/packages/workflows-sdk/src/utils/_playground.ts +++ b/packages/workflows-sdk/src/utils/_playground.ts @@ -26,3 +26,16 @@ workflow() .then((res) => { console.log(res.result) // result: { step2: { test: "test", test2: "step1" } } }) + +/*type type0 = typeof workflow extends ReturnWorkflow + ? T + : never + +function run< + TWorkflow extends ReturnWorkflow, + TData = TWorkflow extends ReturnWorkflow + ? T + : never +>(name: string, options: FlowRunOptions) {} + +const test = run("workflow", { input: "string" })*/ diff --git a/packages/workflows-sdk/src/utils/composer/create-step.ts b/packages/workflows-sdk/src/utils/composer/create-step.ts index 9a7edabd6f..0cd3142352 100644 --- a/packages/workflows-sdk/src/utils/composer/create-step.ts +++ b/packages/workflows-sdk/src/utils/composer/create-step.ts @@ -1,4 +1,11 @@ -import { resolveValue, StepResponse } from "./helpers" +import { + TransactionStepsDefinition, + WorkflowManager, +} from "@medusajs/orchestration" +import { OrchestrationUtils, isString } from "@medusajs/utils" +import { ulid } from "ulid" +import { StepResponse, resolveValue } from "./helpers" +import { proxify } from "./helpers/proxy" import { CreateWorkflowComposerContext, StepExecutionContext, @@ -6,9 +13,6 @@ import { StepFunctionResult, WorkflowData, } from "./type" -import { proxify } from "./helpers/proxy" -import { TransactionStepsDefinition } from "@medusajs/orchestration" -import { isString, OrchestrationUtils } from "@medusajs/utils" /** * The type of invocation function passed to a step. @@ -166,19 +170,37 @@ function applyStep< : undefined, } - stepConfig!.noCompensation = !compensateFn + stepConfig.uuid = ulid() + stepConfig.noCompensation = !compensateFn this.flow.addAction(stepName, stepConfig) - this.handlers.set(stepName, handler) + + if (!this.handlers.has(stepName)) { + this.handlers.set(stepName, handler) + } const ret = { __type: OrchestrationUtils.SymbolWorkflowStep, __step__: stepName, - config: (config: Pick) => { - this.flow.replaceAction(stepName, stepName, { + config: ( + localConfig: { name?: string } & Omit< + TransactionStepsDefinition, + "next" | "uuid" | "action" + > + ) => { + const newStepName = localConfig.name ?? stepName + + delete localConfig.name + + this.handlers.set(newStepName, handler) + + this.flow.replaceAction(stepConfig.uuid!, newStepName, { ...stepConfig, - ...config, + ...localConfig, }) + + WorkflowManager.update(this.workflowId, this.flow, this.handlers) + return proxify(ret) }, } @@ -241,11 +263,14 @@ export function createStep< TInvokeResultCompensateInput >( /** - * The name of the step or its configuration (currently support maxRetries). + * The name of the step or its configuration. */ nameOrConfig: | string - | ({ name: string } & Pick), + | ({ name: string } & Omit< + TransactionStepsDefinition, + "next" | "uuid" | "action" + >), /** * An invocation function that will be executed when the workflow is executed. The function must return an instance of {@link StepResponse}. The constructor of {@link StepResponse} * accepts the output of the step as a first argument, and optionally as a second argument the data to be passed to the compensation function as a parameter. diff --git a/packages/workflows-sdk/src/utils/composer/create-workflow.ts b/packages/workflows-sdk/src/utils/composer/create-workflow.ts index 945dd5cf33..c9f6b66964 100644 --- a/packages/workflows-sdk/src/utils/composer/create-workflow.ts +++ b/packages/workflows-sdk/src/utils/composer/create-workflow.ts @@ -5,7 +5,7 @@ import { WorkflowManager, } from "@medusajs/orchestration" import { LoadedModule, MedusaContainer } from "@medusajs/types" -import { OrchestrationUtils } from "@medusajs/utils" +import { isString, OrchestrationUtils } from "@medusajs/utils" import { ExportedWorkflow, exportWorkflow } from "../../helper" import { proxify } from "./helpers/proxy" import { @@ -63,7 +63,11 @@ global[OrchestrationUtils.SymbolMedusaWorkflowComposerContext] = null * } * ``` */ -type ReturnWorkflow> = { +export type ReturnWorkflow< + TData, + TResult, + THooks extends Record +> = { ( container?: LoadedModule[] | MedusaContainer ): Omit< @@ -73,8 +77,20 @@ type ReturnWorkflow> = { ExportedWorkflow } & THooks & { getName: () => string + } & { + config: (config: TransactionModelOptions) => void } +/** + * Extract the raw type of the expected input data of a workflow. + * + * @example + * type WorkflowInputData = UnwrapWorkflowInputDataType + */ +export type UnwrapWorkflowInputDataType< + T extends ReturnWorkflow +> = T extends ReturnWorkflow ? TData : never + /** * This function creates a workflow with the provided name and a constructor function. * The constructor function builds the workflow from steps created by the {@link createStep} function. @@ -136,9 +152,9 @@ export function createWorkflow< THooks extends Record = Record >( /** - * The name of the workflow. + * The name of the workflow or its configuration. */ - name: string, + nameOrConfig: string | ({ name: string } & TransactionModelOptions), /** * The constructor function that is executed when the `run` method in {@link ReturnWorkflow} is used. * The function can't be an arrow function or an asynchronus function. It also can't directly manipulate data. @@ -151,9 +167,11 @@ export function createWorkflow< [K in keyof TResult]: | WorkflowData | WorkflowDataProperties - }, - options?: TransactionModelOptions + } ): ReturnWorkflow { + const name = isString(nameOrConfig) ? nameOrConfig : nameOrConfig.name + const options = isString(nameOrConfig) ? {} : nameOrConfig + const handlers: WorkflowHandler = new Map() if (WorkflowManager.getWorkflow(name)) { @@ -185,13 +203,17 @@ export function createWorkflow< const inputPlaceHolder = proxify({ __type: OrchestrationUtils.SymbolInputReference, __step__: "", + config: () => { + // TODO: config default value? + throw new Error("Config is not available for the input object.") + }, }) const returnedStep = composer.apply(context, [inputPlaceHolder]) delete global[OrchestrationUtils.SymbolMedusaWorkflowComposerContext] - WorkflowManager.update(name, context.flow, handlers) + WorkflowManager.update(name, context.flow, handlers, options) const workflow = exportWorkflow( name, @@ -206,8 +228,12 @@ export function createWorkflow< container?: LoadedModule[] | MedusaContainer ) => { const workflow_ = workflow(container) + const expandedFlow: any = workflow_ + expandedFlow.config = (config) => { + workflow_.setOptions(config) + } - return workflow_ + return expandedFlow } let shouldRegisterHookHandler = true diff --git a/packages/workflows-sdk/src/utils/composer/type.ts b/packages/workflows-sdk/src/utils/composer/type.ts index 3e05390c50..2ef18de247 100644 --- a/packages/workflows-sdk/src/utils/composer/type.ts +++ b/packages/workflows-sdk/src/utils/composer/type.ts @@ -37,13 +37,8 @@ export type StepFunction = (keyof TInput extends [] }) & WorkflowDataProperties<{ [K in keyof TOutput]: TOutput[K] - }> & { - config( - config: Pick - ): WorkflowData<{ - [K in keyof TOutput]: TOutput[K] - }> - } & WorkflowDataProperties<{ + }> & + WorkflowDataProperties<{ [K in keyof TOutput]: TOutput[K] }> @@ -62,7 +57,22 @@ export type WorkflowData = (T extends object [Key in keyof T]: WorkflowData } : WorkflowDataProperties) & - WorkflowDataProperties + WorkflowDataProperties & { + config( + config: { name?: string } & Omit< + TransactionStepsDefinition, + "next" | "uuid" | "action" + > + ): T extends object + ? WorkflowData< + T extends object + ? { + [K in keyof T]: T[K] + } + : T + > + : T + } export type CreateWorkflowComposerContext = { hooks_: string[] diff --git a/yarn.lock b/yarn.lock index dcfec06735..d32f10e6b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8699,7 +8699,61 @@ __metadata: languageName: unknown linkType: soft -"@medusajs/workflows-sdk@^0.1.1, @medusajs/workflows-sdk@workspace:packages/workflows-sdk": +"@medusajs/workflow-engine-inmemory@workspace:packages/workflow-engine-inmemory": + version: 0.0.0-use.local + resolution: "@medusajs/workflow-engine-inmemory@workspace:packages/workflow-engine-inmemory" + 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/cli": 5.9.7 + "@mikro-orm/core": 5.9.7 + "@mikro-orm/migrations": 5.9.7 + "@mikro-orm/postgresql": 5.9.7 + awilix: ^8.0.0 + cross-env: ^5.2.1 + dotenv: ^16.1.4 + jest: ^29.6.3 + knex: 2.4.2 + 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 + languageName: unknown + linkType: soft + +"@medusajs/workflow-engine-redis@workspace:packages/workflow-engine-redis": + version: 0.0.0-use.local + resolution: "@medusajs/workflow-engine-redis@workspace:packages/workflow-engine-redis" + 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/cli": 5.9.7 + "@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 + cross-env: ^5.2.1 + dotenv: ^16.1.4 + ioredis: ^5.3.2 + jest: ^29.6.3 + knex: 2.4.2 + 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 + languageName: unknown + linkType: soft + +"@medusajs/workflows-sdk@^0.1.0, @medusajs/workflows-sdk@^0.1.1, @medusajs/workflows-sdk@workspace:packages/workflows-sdk": version: 0.0.0-use.local resolution: "@medusajs/workflows-sdk@workspace:packages/workflows-sdk" dependencies: @@ -21778,6 +21832,23 @@ __metadata: languageName: node linkType: hard +"bullmq@npm:^5.1.3": + version: 5.1.3 + resolution: "bullmq@npm:5.1.3" + dependencies: + cron-parser: ^4.6.0 + glob: ^8.0.3 + ioredis: ^5.3.2 + lodash: ^4.17.21 + msgpackr: ^1.10.1 + node-abort-controller: ^3.1.1 + semver: ^7.5.4 + tslib: ^2.0.0 + uuid: ^9.0.0 + checksum: dc2177dfd736b2d008ccab1ba9f77f80cc730ce6197c9ffa0f37327e1cf34bd8b97d83ee9f9008253ef0c0854bbd04f8c925889a3370a0899e8f5c7a34fd3ab3 + languageName: node + linkType: hard + "bundle-name@npm:^3.0.0": version: 3.0.0 resolution: "bundle-name@npm:3.0.0" @@ -38529,6 +38600,18 @@ __metadata: languageName: node linkType: hard +"msgpackr@npm:^1.10.1": + version: 1.10.1 + resolution: "msgpackr@npm:1.10.1" + dependencies: + msgpackr-extract: ^3.0.2 + dependenciesMeta: + msgpackr-extract: + optional: true + checksum: 2e6ed91af89ec15d1e5595c5b837a4adcbb185b0fbd4773d728ced89ab4abbdd3401f6777b193d487d9807e1cb0cf3da1ba9a0bd2d5a553e22355cea84a36bab + languageName: node + linkType: hard + "msgpackr@npm:^1.5.4, msgpackr@npm:^1.6.2": version: 1.9.5 resolution: "msgpackr@npm:1.9.5" @@ -38826,6 +38909,13 @@ __metadata: languageName: node linkType: hard +"node-abort-controller@npm:^3.1.1": + version: 3.1.1 + resolution: "node-abort-controller@npm:3.1.1" + checksum: f7ad0e7a8e33809d4f3a0d1d65036a711c39e9d23e0319d80ebe076b9a3b4432b4d6b86a7fab65521de3f6872ffed36fc35d1327487c48eb88c517803403eda3 + languageName: node + linkType: hard + "node-addon-api@npm:^4.3.0": version: 4.3.0 resolution: "node-addon-api@npm:4.3.0"