feat(orchestration,workflows-sdk,core-flows): workflow cancel (#6778)
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/orchestration": patch
|
||||||
|
"@medusajs/workflows-sdk": patch
|
||||||
|
"@medusajs/core-flows": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Feat: workflow cancel
|
||||||
@@ -204,6 +204,113 @@ medusaIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should revert if the cart creation fails", async () => {
|
||||||
|
const region = await regionModuleService.create({
|
||||||
|
name: "US",
|
||||||
|
currency_code: "usd",
|
||||||
|
})
|
||||||
|
|
||||||
|
const salesChannel = await scModuleService.create({
|
||||||
|
name: "Webshop",
|
||||||
|
})
|
||||||
|
|
||||||
|
const location = await stockLocationModule.create({
|
||||||
|
name: "Warehouse",
|
||||||
|
})
|
||||||
|
|
||||||
|
const [product] = await productModule.create([
|
||||||
|
{
|
||||||
|
title: "Test product",
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: "Test variant",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const inventoryItem = await inventoryModule.create({
|
||||||
|
sku: "inv-1234",
|
||||||
|
})
|
||||||
|
|
||||||
|
await inventoryModule.createInventoryLevels([
|
||||||
|
{
|
||||||
|
inventory_item_id: inventoryItem.id,
|
||||||
|
location_id: location.id,
|
||||||
|
stocked_quantity: 2,
|
||||||
|
reserved_quantity: 0,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const priceSet = await pricingModule.create({
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: 3000,
|
||||||
|
currency_code: "usd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await remoteLink.create([
|
||||||
|
{
|
||||||
|
[Modules.PRODUCT]: {
|
||||||
|
variant_id: product.variants[0].id,
|
||||||
|
},
|
||||||
|
[Modules.PRICING]: {
|
||||||
|
price_set_id: priceSet.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[Modules.SALES_CHANNEL]: {
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
},
|
||||||
|
[Modules.STOCK_LOCATION]: {
|
||||||
|
stock_location_id: location.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[Modules.PRODUCT]: {
|
||||||
|
variant_id: product.variants[0].id,
|
||||||
|
},
|
||||||
|
[Modules.INVENTORY]: {
|
||||||
|
inventory_item_id: inventoryItem.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const workflow = createCartWorkflow(appContainer)
|
||||||
|
|
||||||
|
workflow.addAction(
|
||||||
|
"throw",
|
||||||
|
{
|
||||||
|
invoke: async function failStep() {
|
||||||
|
throw new Error(`Failed to create cart`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
noCompensation: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { transaction } = await workflow.run({
|
||||||
|
throwOnError: false,
|
||||||
|
input: {
|
||||||
|
email: "tony@stark.com",
|
||||||
|
currency_code: "usd",
|
||||||
|
region_id: region.id,
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
variant_id: product.variants[0].id,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(transaction.flow.state).toEqual("reverted")
|
||||||
|
})
|
||||||
|
|
||||||
it("should throw when no regions exist", async () => {
|
it("should throw when no regions exist", async () => {
|
||||||
await regionModuleService.delete(defaultRegion.id)
|
await regionModuleService.delete(defaultRegion.id)
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,20 @@ interface StepInput {
|
|||||||
export const updateTaxLinesStepId = "update-tax-lines-step"
|
export const updateTaxLinesStepId = "update-tax-lines-step"
|
||||||
export const updateTaxLinesStep = createStep(
|
export const updateTaxLinesStep = createStep(
|
||||||
updateTaxLinesStepId,
|
updateTaxLinesStepId,
|
||||||
async (input: StepInput, { container }) => {
|
async (input: StepInput, { container, idempotencyKey }) => {
|
||||||
// TODO: manually trigger rollback on workflow when step fails
|
const { transaction } = await updateTaxLinesWorkflow(container).run({
|
||||||
await updateTaxLinesWorkflow(container).run({ input })
|
input,
|
||||||
|
})
|
||||||
|
|
||||||
return new StepResponse(null)
|
return new StepResponse(null, { transaction })
|
||||||
|
},
|
||||||
|
async (flow, { container }) => {
|
||||||
|
if (!flow) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateTaxLinesWorkflow(container).cancel({
|
||||||
|
transaction: flow.transaction,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -781,7 +781,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
if (flow.state === TransactionState.FAILED) {
|
if (flow.state === TransactionState.FAILED) {
|
||||||
throw new MedusaError(
|
throw new MedusaError(
|
||||||
MedusaError.Types.NOT_ALLOWED,
|
MedusaError.Types.NOT_ALLOWED,
|
||||||
`Cannot revert a perment failed transaction.`
|
`Cannot revert a permanent failed transaction.`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
MedusaModuleType,
|
MedusaModuleType,
|
||||||
createMedusaContainer,
|
createMedusaContainer,
|
||||||
isDefined,
|
isDefined,
|
||||||
|
isString,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
import { asValue } from "awilix"
|
import { asValue } from "awilix"
|
||||||
import {
|
import {
|
||||||
@@ -348,17 +349,16 @@ export class LocalWorkflow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async cancel(
|
async cancel(
|
||||||
uniqueTransactionId: string,
|
transactionOrTransactionId: string | DistributedTransaction,
|
||||||
context?: Context,
|
context?: Context,
|
||||||
subscribe?: DistributedTransactionEvents
|
subscribe?: DistributedTransactionEvents
|
||||||
) {
|
) {
|
||||||
this.medusaContext = context
|
this.medusaContext = context
|
||||||
const { orchestrator } = this.workflow
|
const { orchestrator } = this.workflow
|
||||||
|
|
||||||
const transaction = await this.getRunningTransaction(
|
const transaction = isString(transactionOrTransactionId)
|
||||||
uniqueTransactionId,
|
? await this.getRunningTransaction(transactionOrTransactionId, context)
|
||||||
context
|
: transactionOrTransactionId
|
||||||
)
|
|
||||||
|
|
||||||
const { cleanUpEventListeners } = this.registerEventCallbacks({
|
const { cleanUpEventListeners } = this.registerEventCallbacks({
|
||||||
orchestrator,
|
orchestrator,
|
||||||
|
|||||||
@@ -2086,4 +2086,40 @@ describe("Workflow composer", function () {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should cancel the workflow after completed", async () => {
|
||||||
|
const mockStep1Fn = jest.fn().mockImplementation(function (input) {
|
||||||
|
return new StepResponse({ obj: "return from 1" }, { data: "data" })
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockCompensateSte1 = jest.fn().mockImplementation(function (input) {
|
||||||
|
return input
|
||||||
|
})
|
||||||
|
|
||||||
|
const step1 = createStep("step1", mockStep1Fn, mockCompensateSte1)
|
||||||
|
|
||||||
|
const workflow = createWorkflow("workflow1", function (input) {
|
||||||
|
return step1(input)
|
||||||
|
})
|
||||||
|
|
||||||
|
const workflowInput = { test: "payload1" }
|
||||||
|
const { transaction } = await workflow().run({
|
||||||
|
input: workflowInput,
|
||||||
|
throwOnError: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockStep1Fn).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockCompensateSte1).toHaveBeenCalledTimes(0)
|
||||||
|
|
||||||
|
await workflow().cancel({
|
||||||
|
transaction,
|
||||||
|
throwOnError: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockStep1Fn).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockCompensateSte1).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
expect(mockStep1Fn.mock.calls[0][0]).toEqual(workflowInput)
|
||||||
|
expect(mockCompensateSte1.mock.calls[0][0]).toEqual({ data: "data" })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { exportWorkflow } from "../workflow-export"
|
|
||||||
import { createMedusaContainer } from "@medusajs/utils"
|
import { createMedusaContainer } from "@medusajs/utils"
|
||||||
|
import { exportWorkflow } from "../workflow-export"
|
||||||
|
|
||||||
jest.mock("@medusajs/orchestration", () => {
|
jest.mock("@medusajs/orchestration", () => {
|
||||||
return {
|
return {
|
||||||
@@ -46,6 +46,17 @@ jest.mock("@medusajs/orchestration", () => {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
cancel: jest.fn(() => {
|
||||||
|
return {
|
||||||
|
getErrors: jest.fn(),
|
||||||
|
getState: jest.fn(() => "reverted"),
|
||||||
|
getContext: jest.fn(() => {
|
||||||
|
return {
|
||||||
|
invoke: { result_step: "invoke_test" },
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ export type FlowRegisterStepFailureOptions<TData = unknown> = {
|
|||||||
events?: DistributedTransactionEvents
|
events?: DistributedTransactionEvents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FlowCancelOptions = {
|
||||||
|
transaction?: DistributedTransaction
|
||||||
|
transactionId?: string
|
||||||
|
context?: Context
|
||||||
|
throwOnError?: boolean
|
||||||
|
events?: DistributedTransactionEvents
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkflowResult<TResult = unknown> = {
|
export type WorkflowResult<TResult = unknown> = {
|
||||||
errors: TransactionStepError[]
|
errors: TransactionStepError[]
|
||||||
transaction: DistributedTransaction
|
transaction: DistributedTransaction
|
||||||
@@ -80,6 +88,7 @@ export type ExportedWorkflow<
|
|||||||
TResultOverride extends undefined ? TResult : TResultOverride
|
TResultOverride extends undefined ? TResult : TResultOverride
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
|
cancel: (args?: FlowCancelOptions) => Promise<WorkflowResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MainExportedWorkflow<TData = unknown, TResult = unknown> = {
|
export type MainExportedWorkflow<TData = unknown, TResult = unknown> = {
|
||||||
@@ -88,12 +97,12 @@ export type MainExportedWorkflow<TData = unknown, TResult = unknown> = {
|
|||||||
container?: LoadedModule[] | MedusaContainer
|
container?: LoadedModule[] | MedusaContainer
|
||||||
): Omit<
|
): Omit<
|
||||||
LocalWorkflow,
|
LocalWorkflow,
|
||||||
"run" | "registerStepSuccess" | "registerStepFailure"
|
"run" | "registerStepSuccess" | "registerStepFailure" | "cancel"
|
||||||
> &
|
> &
|
||||||
ExportedWorkflow<TData, TResult, TDataOverride, TResultOverride>
|
ExportedWorkflow<TData, TResult, TDataOverride, TResultOverride>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* You can also directly call run, registerStepSuccess and registerStepFailure on the exported workflow
|
* You can also directly call run, registerStepSuccess, registerStepFailure and cancel on the exported workflow
|
||||||
*/
|
*/
|
||||||
|
|
||||||
run<TDataOverride = undefined, TResultOverride = undefined>(
|
run<TDataOverride = undefined, TResultOverride = undefined>(
|
||||||
@@ -131,6 +140,12 @@ export type MainExportedWorkflow<TData = unknown, TResult = unknown> = {
|
|||||||
TResultOverride extends undefined ? TResult : TResultOverride
|
TResultOverride extends undefined ? TResult : TResultOverride
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
|
|
||||||
|
cancel(
|
||||||
|
args?: FlowCancelOptions & {
|
||||||
|
container?: LoadedModule[] | MedusaContainer
|
||||||
|
}
|
||||||
|
): Promise<WorkflowResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
function createContextualWorkflowRunner<
|
function createContextualWorkflowRunner<
|
||||||
@@ -152,7 +167,10 @@ function createContextualWorkflowRunner<
|
|||||||
wrappedInput?: boolean
|
wrappedInput?: boolean
|
||||||
}
|
}
|
||||||
container?: LoadedModule[] | MedusaContainer
|
container?: LoadedModule[] | MedusaContainer
|
||||||
}): Omit<LocalWorkflow, "run" | "registerStepSuccess" | "registerStepFailure"> &
|
}): Omit<
|
||||||
|
LocalWorkflow,
|
||||||
|
"run" | "registerStepSuccess" | "registerStepFailure" | "cancel"
|
||||||
|
> &
|
||||||
ExportedWorkflow<TData, TResult, TDataOverride, TResultOverride> {
|
ExportedWorkflow<TData, TResult, TDataOverride, TResultOverride> {
|
||||||
if (!container) {
|
if (!container) {
|
||||||
container = MedusaModule.getLoadedModules().map(
|
container = MedusaModule.getLoadedModules().map(
|
||||||
@@ -165,10 +183,11 @@ function createContextualWorkflowRunner<
|
|||||||
const originalRun = flow.run.bind(flow)
|
const originalRun = flow.run.bind(flow)
|
||||||
const originalRegisterStepSuccess = flow.registerStepSuccess.bind(flow)
|
const originalRegisterStepSuccess = flow.registerStepSuccess.bind(flow)
|
||||||
const originalRegisterStepFailure = flow.registerStepFailure.bind(flow)
|
const originalRegisterStepFailure = flow.registerStepFailure.bind(flow)
|
||||||
|
const originalCancel = flow.cancel.bind(flow)
|
||||||
|
|
||||||
const originalExecution = async (
|
const originalExecution = async (
|
||||||
method,
|
method,
|
||||||
{ throwOnError, resultFrom },
|
{ throwOnError, resultFrom, isCancel = false },
|
||||||
...args
|
...args
|
||||||
) => {
|
) => {
|
||||||
const transaction = await method.apply(method, args)
|
const transaction = await method.apply(method, args)
|
||||||
@@ -176,7 +195,14 @@ function createContextualWorkflowRunner<
|
|||||||
const errors = transaction.getErrors(TransactionHandlerType.INVOKE)
|
const errors = transaction.getErrors(TransactionHandlerType.INVOKE)
|
||||||
|
|
||||||
const failedStatus = [TransactionState.FAILED, TransactionState.REVERTED]
|
const failedStatus = [TransactionState.FAILED, TransactionState.REVERTED]
|
||||||
if (failedStatus.includes(transaction.getState()) && throwOnError) {
|
const isCancelled =
|
||||||
|
isCancel && transaction.getState() === TransactionState.REVERTED
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isCancelled &&
|
||||||
|
failedStatus.includes(transaction.getState()) &&
|
||||||
|
throwOnError
|
||||||
|
) {
|
||||||
const errorMessage = errors
|
const errorMessage = errors
|
||||||
?.map((err) => `${err.error?.message}${EOL}${err.error?.stack}`)
|
?.map((err) => `${err.error?.message}${EOL}${err.error?.stack}`)
|
||||||
?.join(`${EOL}`)
|
?.join(`${EOL}`)
|
||||||
@@ -316,6 +342,39 @@ function createContextualWorkflowRunner<
|
|||||||
}
|
}
|
||||||
flow.registerStepFailure = newRegisterStepFailure as any
|
flow.registerStepFailure = newRegisterStepFailure as any
|
||||||
|
|
||||||
|
const newCancel = async (
|
||||||
|
{
|
||||||
|
transaction,
|
||||||
|
transactionId,
|
||||||
|
context: outerContext,
|
||||||
|
throwOnError,
|
||||||
|
events,
|
||||||
|
}: FlowCancelOptions = {
|
||||||
|
throwOnError: true,
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
throwOnError ??= true
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
...outerContext,
|
||||||
|
transactionId,
|
||||||
|
__type: MedusaContextType,
|
||||||
|
}
|
||||||
|
|
||||||
|
return await originalExecution(
|
||||||
|
originalCancel,
|
||||||
|
{
|
||||||
|
throwOnError,
|
||||||
|
resultFrom: undefined,
|
||||||
|
isCancel: true,
|
||||||
|
},
|
||||||
|
transaction ?? transactionId,
|
||||||
|
context,
|
||||||
|
events
|
||||||
|
)
|
||||||
|
}
|
||||||
|
flow.cancel = newCancel as any
|
||||||
|
|
||||||
return flow as unknown as LocalWorkflow &
|
return flow as unknown as LocalWorkflow &
|
||||||
ExportedWorkflow<TData, TResult, TDataOverride, TResultOverride>
|
ExportedWorkflow<TData, TResult, TDataOverride, TResultOverride>
|
||||||
}
|
}
|
||||||
@@ -335,7 +394,7 @@ export const exportWorkflow = <TData = unknown, TResult = unknown>(
|
|||||||
container?: LoadedModule[] | MedusaContainer
|
container?: LoadedModule[] | MedusaContainer
|
||||||
): Omit<
|
): Omit<
|
||||||
LocalWorkflow,
|
LocalWorkflow,
|
||||||
"run" | "registerStepSuccess" | "registerStepFailure"
|
"run" | "registerStepSuccess" | "registerStepFailure" | "cancel"
|
||||||
> &
|
> &
|
||||||
ExportedWorkflow<TData, TResult, TDataOverride, TResultOverride> {
|
ExportedWorkflow<TData, TResult, TDataOverride, TResultOverride> {
|
||||||
return createContextualWorkflowRunner<
|
return createContextualWorkflowRunner<
|
||||||
@@ -353,11 +412,15 @@ export const exportWorkflow = <TData = unknown, TResult = unknown>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buildRunnerFn = <
|
const buildRunnerFn = <
|
||||||
TAction extends "run" | "registerStepSuccess" | "registerStepFailure",
|
TAction extends
|
||||||
|
| "run"
|
||||||
|
| "registerStepSuccess"
|
||||||
|
| "registerStepFailure"
|
||||||
|
| "cancel",
|
||||||
TDataOverride,
|
TDataOverride,
|
||||||
TResultOverride
|
TResultOverride
|
||||||
>(
|
>(
|
||||||
action: "run" | "registerStepSuccess" | "registerStepFailure",
|
action: "run" | "registerStepSuccess" | "registerStepFailure" | "cancel",
|
||||||
container?: LoadedModule[] | MedusaContainer
|
container?: LoadedModule[] | MedusaContainer
|
||||||
) => {
|
) => {
|
||||||
const contextualRunner = createContextualWorkflowRunner<
|
const contextualRunner = createContextualWorkflowRunner<
|
||||||
@@ -467,6 +530,21 @@ export const exportWorkflow = <TData = unknown, TResult = unknown>(
|
|||||||
)(inputArgs)
|
)(inputArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exportedWorkflow.cancel = async (
|
||||||
|
args?: FlowCancelOptions & {
|
||||||
|
container?: LoadedModule[] | MedusaContainer
|
||||||
|
}
|
||||||
|
): Promise<WorkflowResult> => {
|
||||||
|
const container = args?.container
|
||||||
|
delete args?.container
|
||||||
|
const inputArgs = { ...args } as FlowCancelOptions
|
||||||
|
|
||||||
|
return await buildRunnerFn<"cancel", unknown, unknown>(
|
||||||
|
"cancel",
|
||||||
|
container
|
||||||
|
)(inputArgs)
|
||||||
|
}
|
||||||
|
|
||||||
MedusaWorkflow.registerWorkflow(workflowId, exportedWorkflow)
|
MedusaWorkflow.registerWorkflow(workflowId, exportedWorkflow)
|
||||||
return exportedWorkflow
|
return exportedWorkflow
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
TransactionStepsDefinition,
|
TransactionStepsDefinition,
|
||||||
WorkflowManager,
|
WorkflowManager,
|
||||||
} from "@medusajs/orchestration"
|
} from "@medusajs/orchestration"
|
||||||
import { OrchestrationUtils, isString } from "@medusajs/utils"
|
import { OrchestrationUtils, deepCopy, isString } from "@medusajs/utils"
|
||||||
import { ulid } from "ulid"
|
import { ulid } from "ulid"
|
||||||
import { StepResponse, resolveValue } from "./helpers"
|
import { StepResponse, resolveValue } from "./helpers"
|
||||||
import { proxify } from "./helpers/proxy"
|
import { proxify } from "./helpers/proxy"
|
||||||
@@ -168,8 +168,8 @@ function applyStep<
|
|||||||
stepOutput?.__type ===
|
stepOutput?.__type ===
|
||||||
OrchestrationUtils.SymbolWorkflowStepResponse
|
OrchestrationUtils.SymbolWorkflowStepResponse
|
||||||
? stepOutput.compensateInput &&
|
? stepOutput.compensateInput &&
|
||||||
JSON.parse(JSON.stringify(stepOutput.compensateInput))
|
deepCopy(stepOutput.compensateInput)
|
||||||
: stepOutput && JSON.parse(JSON.stringify(stepOutput))
|
: stepOutput && deepCopy(stepOutput)
|
||||||
|
|
||||||
const args = [invokeResult, executionContext]
|
const args = [invokeResult, executionContext]
|
||||||
const output = await compensateFn.apply(this, args)
|
const output = await compensateFn.apply(this, args)
|
||||||
|
|||||||
Reference in New Issue
Block a user