fix(workflows-sdk): Miss match context usage within run as step (#12449)

**What**

Currently, runAsStep keep reference of the workflow context that is being run as step, except that the step is composed for the current workflow composition and not the workflow being run as a step. Therefore, the context are currently miss matched leading to wrong configuration being used in case of async workflows.

**BUG**
This fix allow the runAsStep to use the current composition context to configure the step for the sub workflow to be run

**BUG BREAKING**
fix the step config wrongly used to wrap async step handlers. Now steps configured async through .config that returns a new step response will indeed marked itself as success without the need for background execution or calling setStepSuccess (as it was expected originally)

**FEATURE**
This pr also add support for cancelling running transaction, the transaction will be marked as being cancelled, once the current step finished, it will cancel the transaction to start compensating all previous steps including itself

Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2025-05-14 13:28:16 +00:00
committed by GitHub
co-authored by Carlos R. L. Rodrigues
parent ab22faaa52
commit 7fdbf2a965
19 changed files with 603 additions and 64 deletions
@@ -98,3 +98,19 @@ export class SkipExecutionError extends Error {
this.name = "SkipExecutionError" this.name = "SkipExecutionError"
} }
} }
export class SkipCancelledExecutionError extends Error {
static isSkipCancelledExecutionError(
error: Error
): error is SkipCancelledExecutionError {
return (
error instanceof SkipCancelledExecutionError ||
error?.name === "SkipCancelledExecutionError"
)
}
constructor(message?: string) {
super(message)
this.name = "SkipCancelledExecutionError"
}
}
@@ -31,6 +31,7 @@ import {
import { EventEmitter } from "events" import { EventEmitter } from "events"
import { import {
PermanentStepFailureError, PermanentStepFailureError,
SkipCancelledExecutionError,
SkipExecutionError, SkipExecutionError,
SkipStepResponse, SkipStepResponse,
TransactionStepTimeoutError, TransactionStepTimeoutError,
@@ -494,6 +495,7 @@ export class TransactionOrchestrator extends EventEmitter {
response: unknown response: unknown
): Promise<{ ): Promise<{
stopExecution: boolean stopExecution: boolean
transactionIsCancelling?: boolean
}> { }> {
const hasStepTimedOut = const hasStepTimedOut =
step.getStates().state === TransactionStepState.TIMEOUT step.getStates().state === TransactionStepState.TIMEOUT
@@ -519,10 +521,20 @@ export class TransactionOrchestrator extends EventEmitter {
} }
let shouldEmit = true let shouldEmit = true
let transactionIsCancelling = false
try { try {
await transaction.saveCheckpoint() await transaction.saveCheckpoint()
} catch (error) { } catch (error) {
shouldEmit = false if (
!SkipCancelledExecutionError.isSkipCancelledExecutionError(error) &&
!SkipExecutionError.isSkipExecutionError(error)
) {
throw error
}
transactionIsCancelling =
SkipCancelledExecutionError.isSkipCancelledExecutionError(error)
shouldEmit = !SkipExecutionError.isSkipExecutionError(error)
} }
const cleaningUp: Promise<unknown>[] = [] const cleaningUp: Promise<unknown>[] = []
@@ -544,6 +556,7 @@ export class TransactionOrchestrator extends EventEmitter {
return { return {
stopExecution: !shouldEmit, stopExecution: !shouldEmit,
transactionIsCancelling,
} }
} }
@@ -555,6 +568,7 @@ export class TransactionOrchestrator extends EventEmitter {
step: TransactionStep step: TransactionStep
}): Promise<{ }): Promise<{
stopExecution: boolean stopExecution: boolean
transactionIsCancelling?: boolean
}> { }> {
const hasStepTimedOut = const hasStepTimedOut =
step.getStates().state === TransactionStepState.TIMEOUT step.getStates().state === TransactionStepState.TIMEOUT
@@ -565,13 +579,22 @@ export class TransactionOrchestrator extends EventEmitter {
} }
let shouldEmit = true let shouldEmit = true
let transactionIsCancelling = false
try { try {
await transaction.saveCheckpoint() await transaction.saveCheckpoint()
} catch (error) { } catch (error) {
if (
!SkipCancelledExecutionError.isSkipCancelledExecutionError(error) &&
!SkipExecutionError.isSkipExecutionError(error)
) {
throw error
}
transactionIsCancelling =
SkipCancelledExecutionError.isSkipCancelledExecutionError(error)
if (SkipExecutionError.isSkipExecutionError(error)) { if (SkipExecutionError.isSkipExecutionError(error)) {
shouldEmit = false shouldEmit = false
} else {
throw error
} }
} }
@@ -592,6 +615,7 @@ export class TransactionOrchestrator extends EventEmitter {
return { return {
stopExecution: !shouldEmit, stopExecution: !shouldEmit,
transactionIsCancelling,
} }
} }
@@ -649,6 +673,7 @@ export class TransactionOrchestrator extends EventEmitter {
timeoutError?: TransactionStepTimeoutError | TransactionTimeoutError timeoutError?: TransactionStepTimeoutError | TransactionTimeoutError
): Promise<{ ): Promise<{
stopExecution: boolean stopExecution: boolean
transactionIsCancelling?: boolean
}> { }> {
if (SkipExecutionError.isSkipExecutionError(error)) { if (SkipExecutionError.isSkipExecutionError(error)) {
return { return {
@@ -734,14 +759,23 @@ export class TransactionOrchestrator extends EventEmitter {
} }
} }
let transactionIsCancelling = false
let shouldEmit = true let shouldEmit = true
try { try {
await transaction.saveCheckpoint() await transaction.saveCheckpoint()
} catch (error) { } catch (error) {
if (
!SkipCancelledExecutionError.isSkipCancelledExecutionError(error) &&
!SkipExecutionError.isSkipExecutionError(error)
) {
throw error
}
transactionIsCancelling =
SkipCancelledExecutionError.isSkipCancelledExecutionError(error)
if (SkipExecutionError.isSkipExecutionError(error)) { if (SkipExecutionError.isSkipExecutionError(error)) {
shouldEmit = false shouldEmit = false
} else {
throw error
} }
} }
@@ -760,6 +794,7 @@ export class TransactionOrchestrator extends EventEmitter {
return { return {
stopExecution: !shouldEmit, stopExecution: !shouldEmit,
transactionIsCancelling,
} }
} }
@@ -785,15 +820,30 @@ export class TransactionOrchestrator extends EventEmitter {
return return
} }
const execution: Promise<void | unknown>[] = [] const stepsShouldContinueExecution = nextSteps.next.map((step) => {
for (const step of nextSteps.next) {
const { shouldContinueExecution } = this.prepareStepForExecution( const { shouldContinueExecution } = this.prepareStepForExecution(
step, step,
flow flow
) )
// Should stop the execution if next step cant be handled return shouldContinueExecution
if (!shouldContinueExecution) { })
await transaction.saveCheckpoint().catch((error) => {
if (SkipExecutionError.isSkipExecutionError(error)) {
continueExecution = false
return
}
throw error
})
const execution: Promise<void | unknown>[] = []
let i = 0
for (const step of nextSteps.next) {
const stepIndex = i++
if (!stepsShouldContinueExecution[stepIndex]) {
continue continue
} }
@@ -813,16 +863,6 @@ export class TransactionOrchestrator extends EventEmitter {
// Compute current transaction state // Compute current transaction state
await this.computeCurrentTransactionState(transaction) await this.computeCurrentTransactionState(transaction)
// Save checkpoint before executing step
await transaction.saveCheckpoint().catch((error) => {
if (SkipExecutionError.isSkipExecutionError(error)) {
continueExecution = false
return
}
throw error
})
if (!continueExecution) { if (!continueExecution) {
break break
} }
@@ -1130,6 +1170,10 @@ export class TransactionOrchestrator extends EventEmitter {
response response
) )
if (ret.transactionIsCancelling) {
return await this.cancelTransaction(transaction)
}
if (isAsync && !ret.stopExecution) { if (isAsync && !ret.stopExecution) {
// Schedule to continue the execution of async steps because they are not awaited on purpose and can be handled by another machine // Schedule to continue the execution of async steps because they are not awaited on purpose and can be handled by another machine
await transaction.scheduleRetry(step, 0) await transaction.scheduleRetry(step, 0)
@@ -1156,12 +1200,16 @@ export class TransactionOrchestrator extends EventEmitter {
) )
} }
await TransactionOrchestrator.setStepFailure( const ret = await TransactionOrchestrator.setStepFailure(
transaction, transaction,
step, step,
error, error,
isPermanent ? 0 : step.definition.maxRetries isPermanent ? 0 : step.definition.maxRetries
) )
if (ret.transactionIsCancelling) {
return await this.cancelTransaction(transaction)
}
} }
/** /**
@@ -1245,6 +1293,8 @@ export class TransactionOrchestrator extends EventEmitter {
flow.state = TransactionState.WAITING_TO_COMPENSATE flow.state = TransactionState.WAITING_TO_COMPENSATE
flow.cancelledAt = Date.now() flow.cancelledAt = Date.now()
await transaction.saveCheckpoint()
await this.executeNext(transaction) await this.executeNext(transaction)
} }
@@ -1667,12 +1717,17 @@ export class TransactionOrchestrator extends EventEmitter {
transaction: curTransaction, transaction: curTransaction,
}) })
await TransactionOrchestrator.setStepSuccess( const ret = await TransactionOrchestrator.setStepSuccess(
curTransaction, curTransaction,
step, step,
response response
) )
if (ret.transactionIsCancelling) {
await this.cancelTransaction(curTransaction)
return curTransaction
}
await this.executeNext(curTransaction) await this.executeNext(curTransaction)
} else { } else {
throw new MedusaError( throw new MedusaError(
@@ -1721,13 +1776,18 @@ export class TransactionOrchestrator extends EventEmitter {
transaction: curTransaction, transaction: curTransaction,
}) })
await TransactionOrchestrator.setStepFailure( const ret = await TransactionOrchestrator.setStepFailure(
curTransaction, curTransaction,
step, step,
error, error,
0 0
) )
if (ret.transactionIsCancelling) {
await this.cancelTransaction(curTransaction)
return curTransaction
}
await this.executeNext(curTransaction) await this.executeNext(curTransaction)
} else { } else {
throw new MedusaError( throw new MedusaError(
@@ -402,10 +402,18 @@ export class LocalWorkflow {
this.medusaContext = context this.medusaContext = context
const { orchestrator } = this.workflow const { orchestrator } = this.workflow
const transaction = isString(transactionOrTransactionId) let transaction = isString(transactionOrTransactionId)
? await this.getRunningTransaction(transactionOrTransactionId, context) ? await this.getRunningTransaction(transactionOrTransactionId, context)
: transactionOrTransactionId : transactionOrTransactionId
// not a distributed transaction instance
if (!transaction.getFlow) {
transaction = await this.getRunningTransaction(
(transaction as any).flow.transactionId,
context
)
}
if (this.medusaContext) { if (this.medusaContext) {
this.medusaContext.eventGroupId = this.medusaContext.eventGroupId =
transaction.getFlow().metadata?.eventGroupId transaction.getFlow().metadata?.eventGroupId
@@ -182,7 +182,7 @@ export function applyStep<
compensateFn, compensateFn,
}) })
wrapAsyncHandler(stepConfig, handler) wrapAsyncHandler(newConfig, handler)
this.handlers.set(newStepName, handler) this.handlers.set(newStepName, handler)
@@ -184,11 +184,16 @@ export function createWorkflow<TData, TResult, THooks extends any[]>(
}: { }: {
input: TData input: TData
}): ReturnType<StepFunction<TData, TResult>> => { }): ReturnType<StepFunction<TData, TResult>> => {
// Get current workflow composition context
const workflowCompositionContext =
global[OrchestrationUtils.SymbolMedusaWorkflowComposerContext]
const runAsAsync = workflowCompositionContext.isAsync || context.isAsync
const step = createStep( const step = createStep(
{ {
name: `${name}-as-step`, name: `${name}-as-step`,
async: context.isAsync, async: runAsAsync,
nested: context.isAsync, // if async we flag this is a nested transaction nested: runAsAsync, // if async we flag this is a nested transaction
}, },
async (stepInput: TData, stepContext) => { async (stepInput: TData, stepContext) => {
const { container, ...sharedContext } = stepContext const { container, ...sharedContext } = stepContext
@@ -206,7 +211,7 @@ export function createWorkflow<TData, TResult, THooks extends any[]>(
} }
let transaction let transaction
if (workflowEngine && context.isAsync) { if (workflowEngine && runAsAsync) {
transaction = await workflowEngine.run(name, { transaction = await workflowEngine.run(name, {
input: stepInput as any, input: stepInput as any,
context: executionContext, context: executionContext,
@@ -221,7 +226,7 @@ export function createWorkflow<TData, TResult, THooks extends any[]>(
return new StepResponse( return new StepResponse(
transaction.result, transaction.result,
context.isAsync ? stepContext.transactionId : transaction runAsAsync ? stepContext.transactionId : transaction
) )
}, },
async (transaction, stepContext) => { async (transaction, stepContext) => {
@@ -246,7 +251,7 @@ export function createWorkflow<TData, TResult, THooks extends any[]>(
const transactionId = step.__step__ + "-" + stepContext.transactionId const transactionId = step.__step__ + "-" + stepContext.transactionId
if (workflowEngine && context.isAsync) { if (workflowEngine && runAsAsync) {
await workflowEngine.cancel(name, { await workflowEngine.cancel(name, {
transactionId: transactionId, transactionId: transactionId,
context: executionContext, context: executionContext,
@@ -1,3 +1,4 @@
import { isPresent } from "@medusajs/framework/utils"
import { import {
createStep, createStep,
createWorkflow, createWorkflow,
@@ -24,7 +25,7 @@ const step_1 = createStep(
const step_2 = createStep( const step_2 = createStep(
"step_2", "step_2",
jest.fn((input, context) => { jest.fn((input, context) => {
if (input) { if (isPresent(input)) {
return new StepResponse({ notAsyncResponse: input.hey }) return new StepResponse({ notAsyncResponse: input.hey })
} }
}), }),
@@ -53,7 +54,7 @@ createWorkflow("workflow_1", function (input) {
const ret2 = step_2({ hey: "oh" }) const ret2 = step_2({ hey: "oh" })
step_2({ hey: "async hello" }).config({ step_2().config({
name: "new_step_name", name: "new_step_name",
async: true, async: true,
}) })
@@ -1,3 +1,4 @@
import { isPresent } from "@medusajs/framework/utils"
import { import {
createStep, createStep,
createWorkflow, createWorkflow,
@@ -22,7 +23,7 @@ const step_1 = createStep(
) )
export const workflow2Step2Invoke = jest.fn((input, context) => { export const workflow2Step2Invoke = jest.fn((input, context) => {
if (input) { if (isPresent(input)) {
return new StepResponse({ notAsyncResponse: input.hey }) return new StepResponse({ notAsyncResponse: input.hey })
} }
}) })
@@ -57,7 +58,7 @@ createWorkflow(
step_2({ hey: "oh" }) step_2({ hey: "oh" })
const ret2_async = step_2({ hey: "async hello" }).config({ const ret2_async = step_2().config({
name: "new_step_name", name: "new_step_name",
async: true, async: true,
}) })
@@ -1,3 +1,4 @@
import { isPresent } from "@medusajs/framework/utils"
import { import {
createStep, createStep,
createWorkflow, createWorkflow,
@@ -14,7 +15,7 @@ const step_1 = createStep(
) )
export const conditionalStep2Invoke = jest.fn((input, context) => { export const conditionalStep2Invoke = jest.fn((input, context) => {
if (input) { if (isPresent(input)) {
return new StepResponse({ notAsyncResponse: input.hey }) return new StepResponse({ notAsyncResponse: input.hey })
} }
}) })
@@ -1,3 +1,4 @@
import { isPresent } from "@medusajs/framework/utils"
import { import {
createStep, createStep,
createWorkflow, createWorkflow,
@@ -25,7 +26,7 @@ const step_1 = createStep(
const step_2 = createStep( const step_2 = createStep(
"step_2", "step_2",
jest.fn((input, context) => { jest.fn((input, context) => {
if (input) { if (isPresent(input)) {
return new StepResponse({ notAsyncResponse: input.hey }) return new StepResponse({ notAsyncResponse: input.hey })
} }
}), }),
@@ -1,3 +1,4 @@
import { isPresent } from "@medusajs/framework/utils"
import { import {
createStep, createStep,
createWorkflow, createWorkflow,
@@ -23,7 +24,7 @@ const step_1 = createStep(
export const workflowNotIdempotentWithRetentionStep2Invoke = jest.fn( export const workflowNotIdempotentWithRetentionStep2Invoke = jest.fn(
(input, context) => { (input, context) => {
if (input) { if (isPresent(input)) {
return new StepResponse({ notAsyncResponse: input.hey }) return new StepResponse({ notAsyncResponse: input.hey })
} }
} }
@@ -4,6 +4,7 @@ import {
StepResponse, StepResponse,
WorkflowResponse, WorkflowResponse,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
import { isPresent } from "@medusajs/framework/utils"
const step_1 = createStep( const step_1 = createStep(
"step_1", "step_1",
@@ -25,7 +26,7 @@ const step_1 = createStep(
const step_2 = createStep( const step_2 = createStep(
"step_2", "step_2",
jest.fn((input, context) => { jest.fn((input, context) => {
if (input) { if (isPresent(input)) {
return new StepResponse({ notAsyncResponse: input.hey }) return new StepResponse({ notAsyncResponse: input.hey })
} }
}), }),
@@ -23,9 +23,9 @@ import {
import { moduleIntegrationTestRunner } from "@medusajs/test-utils" import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { WorkflowsModuleService } from "@services" import { WorkflowsModuleService } from "@services"
import { asFunction } from "awilix" import { asFunction } from "awilix"
import { ulid } from "ulid"
import { setTimeout as setTimeoutSync } from "timers" import { setTimeout as setTimeoutSync } from "timers"
import { setTimeout as setTimeoutPromise } from "timers/promises" import { setTimeout as setTimeoutPromise } from "timers/promises"
import { ulid } from "ulid"
import "../__fixtures__" import "../__fixtures__"
import { import {
conditionalStep2Invoke, conditionalStep2Invoke,
@@ -110,6 +110,205 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
}) })
}) })
describe("Cancel transaction", function () {
it("should cancel an ongoing execution with async unfinished yet step", async () => {
const transactionId = "transaction-to-cancel-id"
const step1 = createStep("step1", async () => {
return new StepResponse("step1")
})
const step2 = createStep("step2", async () => {
await setTimeoutPromise(500)
return new StepResponse("step2")
})
const step3 = createStep("step3", async () => {
return new StepResponse("step3")
})
const workflowId = "workflow-to-cancel-id" + ulid()
createWorkflow({ name: workflowId, retentionTime: 60 }, function () {
step1()
step2().config({ async: true })
step3()
return new WorkflowResponse("finished")
})
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
})
await setTimeoutPromise(100)
await workflowOrcModule.cancel(workflowId, {
transactionId,
})
await setTimeoutPromise(1000)
const execution = await workflowOrcModule.listWorkflowExecutions({
transaction_id: transactionId,
})
expect(execution.length).toEqual(1)
expect(execution[0].state).toEqual(TransactionState.REVERTED)
})
it("should cancel a complete execution with a sync workflow running as async", async () => {
const workflowId = "workflow-to-cancel-id" + ulid()
const transactionId = "transaction-to-cancel-id"
const step1 = createStep("step1", async () => {
return new StepResponse("step1")
})
const step2 = createStep("step2", async () => {
return new StepResponse("step2")
})
const step3 = createStep("step3", async () => {
return new StepResponse("step3")
})
const subWorkflowId = "sub-workflow-id" + ulid()
const subWorkflow = createWorkflow(
{ name: subWorkflowId, retentionTime: 60 },
function () {
return new WorkflowResponse(step2())
}
)
createWorkflow({ name: workflowId, retentionTime: 60 }, function () {
step1()
subWorkflow.runAsStep({ input: {} }).config({ async: true })
step3()
return new WorkflowResponse("finished")
})
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
})
await setTimeoutPromise(100)
await workflowOrcModule.cancel(workflowId, {
transactionId,
})
await setTimeoutPromise(500)
const execution = await workflowOrcModule.listWorkflowExecutions({
transaction_id: transactionId,
})
expect(execution.length).toEqual(1)
expect(execution[0].state).toEqual(TransactionState.REVERTED)
})
it("should cancel an ongoing execution with a sync workflow running as async", async () => {
const workflowId = "workflow-to-cancel-id" + ulid()
const transactionId = "transaction-to-cancel-id"
const step1 = createStep("step1", async () => {
return new StepResponse("step1")
})
const step2 = createStep("step2", async () => {
await setTimeoutPromise(500)
return new StepResponse("step2")
})
const step3 = createStep("step3", async () => {
return new StepResponse("step3")
})
const subWorkflowId = "sub-workflow-id" + ulid()
const subWorkflow = createWorkflow(
{ name: subWorkflowId, retentionTime: 60 },
function () {
return new WorkflowResponse(step2())
}
)
createWorkflow({ name: workflowId, retentionTime: 60 }, function () {
step1()
subWorkflow.runAsStep({ input: {} }).config({ async: true })
step3()
return new WorkflowResponse("finished")
})
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
})
await setTimeoutPromise(100)
await workflowOrcModule.cancel(workflowId, {
transactionId,
})
await setTimeoutPromise(1000)
const execution = await workflowOrcModule.listWorkflowExecutions({
transaction_id: transactionId,
})
expect(execution.length).toEqual(1)
expect(execution[0].state).toEqual(TransactionState.REVERTED)
})
it("should cancel an ongoing execution with sync steps only", async () => {
const transactionId = "transaction-to-cancel-id"
const step1 = createStep("step1", async () => {
return new StepResponse("step1")
})
const step2 = createStep("step2", async () => {
await setTimeoutPromise(500)
return new StepResponse("step2")
})
const step3 = createStep("step3", async () => {
return new StepResponse("step3")
})
const workflowId = "workflow-to-cancel-id" + ulid()
createWorkflow({ name: workflowId, retentionTime: 60 }, function () {
step1()
step2()
step3()
return new WorkflowResponse("finished")
})
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
})
await setTimeoutPromise(100)
await workflowOrcModule.cancel(workflowId, {
transactionId,
})
await setTimeoutPromise(1000)
const execution = await workflowOrcModule.listWorkflowExecutions({
transaction_id: transactionId,
})
expect(execution.length).toEqual(1)
expect(execution[0].state).toEqual(TransactionState.REVERTED)
})
})
it("should prevent executing twice the same workflow in perfect concurrency with the same transactionId and non idempotent and not async but retention time is set", async () => { it("should prevent executing twice the same workflow in perfect concurrency with the same transactionId and non idempotent and not async but retention time is set", async () => {
const transactionId = "transaction_id" const transactionId = "transaction_id"
const workflowId = "workflow_id" + ulid() const workflowId = "workflow_id" + ulid()
@@ -130,10 +329,12 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
) )
const [result1, result2] = await promiseAll([ const [result1, result2] = await promiseAll([
workflowOrcModule.run(workflowId, { workflowOrcModule
input: {}, .run(workflowId, {
transactionId, input: {},
}), transactionId,
})
.catch((e) => e),
workflowOrcModule workflowOrcModule
.run(workflowId, { .run(workflowId, {
input: {}, input: {},
@@ -142,8 +343,8 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
.catch((e) => e), .catch((e) => e),
]) ])
expect(result1.result).toEqual("step1") expect(result1.result || result2.result).toEqual("step1")
expect(result2.message).toEqual( expect(result2.message || result1.message).toEqual(
"Transaction already started for transactionId: " + transactionId "Transaction already started for transactionId: " + transactionId
) )
}) })
@@ -362,9 +563,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
expect(workflow2Step2Invoke).toHaveBeenCalledTimes(2) expect(workflow2Step2Invoke).toHaveBeenCalledTimes(2)
expect(workflow2Step2Invoke.mock.calls[0][0]).toEqual({ hey: "oh" }) expect(workflow2Step2Invoke.mock.calls[0][0]).toEqual({ hey: "oh" })
expect(workflow2Step2Invoke.mock.calls[1][0]).toEqual({ expect(workflow2Step2Invoke.mock.calls[1][0]).toEqual({})
hey: "async hello",
})
expect(workflow2Step3Invoke).toHaveBeenCalledTimes(1) expect(workflow2Step3Invoke).toHaveBeenCalledTimes(1)
expect(workflow2Step3Invoke.mock.calls[0][0]).toEqual({ expect(workflow2Step3Invoke.mock.calls[0][0]).toEqual({
@@ -3,6 +3,7 @@ import {
IDistributedSchedulerStorage, IDistributedSchedulerStorage,
IDistributedTransactionStorage, IDistributedTransactionStorage,
SchedulerOptions, SchedulerOptions,
SkipCancelledExecutionError,
SkipExecutionError, SkipExecutionError,
TransactionCheckpoint, TransactionCheckpoint,
TransactionContext, TransactionContext,
@@ -290,6 +291,19 @@ export class InMemoryDistributedTransactionStorage
throw new SkipExecutionError("Already finished by another execution") throw new SkipExecutionError("Already finished by another execution")
} }
// First ensure that the latest execution was not cancelled, otherwise we skip the execution
const latestTransactionCancelledAt = latestUpdatedFlow.cancelledAt
const currentTransactionCancelledAt = currentFlow.cancelledAt
if (
!!latestTransactionCancelledAt &&
currentTransactionCancelledAt == null
) {
throw new SkipCancelledExecutionError(
"Workflow execution has been cancelled during the execution"
)
}
const currentFlowSteps = Object.values(currentFlow.steps || {}) const currentFlowSteps = Object.values(currentFlow.steps || {})
const latestUpdatedFlowSteps = latestUpdatedFlow.steps const latestUpdatedFlowSteps = latestUpdatedFlow.steps
? Object.values( ? Object.values(
@@ -1,3 +1,4 @@
import { isPresent } from "@medusajs/framework/utils"
import { import {
createStep, createStep,
createWorkflow, createWorkflow,
@@ -25,7 +26,7 @@ const step_1 = createStep(
const step_2 = createStep( const step_2 = createStep(
"step_2", "step_2",
jest.fn((input, context) => { jest.fn((input, context) => {
if (input) { if (isPresent(input)) {
return new StepResponse({ notAsyncResponse: input.hey }) return new StepResponse({ notAsyncResponse: input.hey })
} }
}), }),
@@ -54,7 +55,7 @@ createWorkflow("workflow_1", function (input) {
const ret2 = step_2({ hey: "oh" }) const ret2 = step_2({ hey: "oh" })
step_2({ hey: "async hello" }).config({ step_2().config({
name: "new_step_name", name: "new_step_name",
async: true, async: true,
}) })
@@ -1,3 +1,4 @@
import { isPresent } from "@medusajs/framework/utils"
import { import {
createStep, createStep,
createWorkflow, createWorkflow,
@@ -26,7 +27,7 @@ const step_1 = createStep(
const step_2 = createStep( const step_2 = createStep(
"step_2", "step_2",
jest.fn((input, context) => { jest.fn((input, context) => {
if (input) { if (isPresent(input)) {
return new StepResponse({ notAsyncResponse: input.hey }) return new StepResponse({ notAsyncResponse: input.hey })
} }
}), }),
@@ -68,7 +69,7 @@ createWorkflow(
const ret2 = step_2({ hey: "oh" }) const ret2 = step_2({ hey: "oh" })
step_2({ hey: "async hello" }).config({ step_2().config({
name: "new_step_name", name: "new_step_name",
async: true, async: true,
}) })
@@ -3,6 +3,7 @@ import {
createWorkflow, createWorkflow,
StepResponse, StepResponse,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
import { isPresent } from "@medusajs/framework/utils"
const step_1 = createStep( const step_1 = createStep(
"step_1", "step_1",
@@ -23,7 +24,7 @@ const step_1 = createStep(
export const workflowNotIdempotentWithRetentionStep2Invoke = jest.fn( export const workflowNotIdempotentWithRetentionStep2Invoke = jest.fn(
(input, context) => { (input, context) => {
if (input) { if (isPresent(input)) {
return new StepResponse({ notAsyncResponse: input.hey }) return new StepResponse({ notAsyncResponse: input.hey })
} }
} }
@@ -4,6 +4,7 @@ import {
StepResponse, StepResponse,
WorkflowResponse, WorkflowResponse,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
import { isPresent } from "@medusajs/framework/utils"
const step_1 = createStep( const step_1 = createStep(
"step_1", "step_1",
@@ -25,7 +26,7 @@ const step_1 = createStep(
const step_2 = createStep( const step_2 = createStep(
"step_2", "step_2",
jest.fn((input, context) => { jest.fn((input, context) => {
if (input) { if (isPresent(input)) {
return new StepResponse({ notAsyncResponse: input.hey }) return new StepResponse({ notAsyncResponse: input.hey })
} }
}), }),
@@ -30,15 +30,15 @@ import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { asValue } from "awilix" import { asValue } from "awilix"
import { setTimeout as setTimeoutSync } from "timers" import { setTimeout as setTimeoutSync } from "timers"
import { setTimeout } from "timers/promises" import { setTimeout } from "timers/promises"
import { ulid } from "ulid"
import { WorkflowsModuleService } from "../../src/services" import { WorkflowsModuleService } from "../../src/services"
import "../__fixtures__" import "../__fixtures__"
import { createScheduled } from "../__fixtures__/workflow_scheduled"
import { TestDatabase } from "../utils"
import { import {
workflowNotIdempotentWithRetentionStep2Invoke, workflowNotIdempotentWithRetentionStep2Invoke,
workflowNotIdempotentWithRetentionStep3Invoke, workflowNotIdempotentWithRetentionStep3Invoke,
} from "../__fixtures__" } from "../__fixtures__"
import { ulid } from "ulid" import { createScheduled } from "../__fixtures__/workflow_scheduled"
import { TestDatabase } from "../utils"
jest.setTimeout(300000) jest.setTimeout(300000)
@@ -150,9 +150,220 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
}) })
describe("Testing basic workflow", function () { describe("Testing basic workflow", function () {
describe("Cancel transaction", function () {
it("should cancel an ongoing execution with async unfinished yet step", async () => {
const transactionId = "transaction-to-cancel-id"
const step1 = createStep("step1", async () => {
return new StepResponse("step1")
})
const step2 = createStep("step2", async () => {
await setTimeout(500)
return new StepResponse("step2")
})
const step3 = createStep("step3", async () => {
return new StepResponse("step3")
})
const workflowId = "workflow-to-cancel-id" + ulid()
createWorkflow(
{ name: workflowId, retentionTime: 60 },
function () {
step1()
step2().config({ async: true })
step3()
return new WorkflowResponse("finished")
}
)
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
})
await setTimeout(100)
await workflowOrcModule.cancel(workflowId, {
transactionId,
})
await setTimeout(1000)
const execution = await workflowOrcModule.listWorkflowExecutions({
transaction_id: transactionId,
})
expect(execution.length).toEqual(1)
expect(execution[0].state).toEqual(TransactionState.REVERTED)
})
it("should cancel a complete execution with a sync workflow running as async", async () => {
const workflowId = "workflow-to-cancel-id" + ulid()
const transactionId = "transaction-to-cancel-id"
const step1 = createStep("step1", async () => {
return new StepResponse("step1")
})
const step2 = createStep("step2", async () => {
return new StepResponse("step2")
})
const step3 = createStep("step3", async () => {
return new StepResponse("step3")
})
const subWorkflowId = "sub-workflow-id" + ulid()
const subWorkflow = createWorkflow(
{ name: subWorkflowId, retentionTime: 60 },
function () {
return new WorkflowResponse(step2())
}
)
createWorkflow(
{ name: workflowId, retentionTime: 60 },
function () {
step1()
subWorkflow.runAsStep({ input: {} }).config({ async: true })
step3()
return new WorkflowResponse("finished")
}
)
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
})
await setTimeout(100)
await workflowOrcModule.cancel(workflowId, {
transactionId,
})
await setTimeout(500)
const execution = await workflowOrcModule.listWorkflowExecutions({
transaction_id: transactionId,
})
expect(execution.length).toEqual(1)
expect(execution[0].state).toEqual(TransactionState.REVERTED)
})
it("should cancel an ongoing execution with a sync workflow running as async", async () => {
const workflowId = "workflow-to-cancel-id" + ulid()
const transactionId = "transaction-to-cancel-id"
const step1 = createStep("step1", async () => {
return new StepResponse("step1")
})
const step2 = createStep("step2", async () => {
await setTimeout(500)
return new StepResponse("step2")
})
const step3 = createStep("step3", async () => {
return new StepResponse("step3")
})
const subWorkflowId = "sub-workflow-id" + ulid()
const subWorkflow = createWorkflow(
{ name: subWorkflowId, retentionTime: 60 },
function () {
return new WorkflowResponse(step2())
}
)
createWorkflow(
{ name: workflowId, retentionTime: 60 },
function () {
step1()
subWorkflow.runAsStep({ input: {} }).config({ async: true })
step3()
return new WorkflowResponse("finished")
}
)
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
})
await setTimeout(100)
await workflowOrcModule.cancel(workflowId, {
transactionId,
})
await setTimeout(1000)
const execution = await workflowOrcModule.listWorkflowExecutions({
transaction_id: transactionId,
})
expect(execution.length).toEqual(1)
expect(execution[0].state).toEqual(TransactionState.REVERTED)
})
it("should cancel an ongoing execution with sync steps only", async () => {
const transactionId = "transaction-to-cancel-id"
const step1 = createStep("step1", async () => {
return new StepResponse("step1")
})
const step2 = createStep("step2", async () => {
await setTimeout(500)
return new StepResponse("step2")
})
const step3 = createStep("step3", async () => {
return new StepResponse("step3")
})
const workflowId = "workflow-to-cancel-id" + ulid()
createWorkflow(
{ name: workflowId, retentionTime: 60 },
function () {
step1()
step2()
step3()
return new WorkflowResponse("finished")
}
)
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
})
await setTimeout(100)
await workflowOrcModule.cancel(workflowId, {
transactionId,
})
await setTimeout(1000)
const execution = await workflowOrcModule.listWorkflowExecutions({
transaction_id: transactionId,
})
expect(execution.length).toEqual(1)
expect(execution[0].state).toEqual(TransactionState.REVERTED)
})
})
it("should prevent executing twice the same workflow in perfect concurrency with the same transactionId and non idempotent and not async but retention time is set", async () => { it("should prevent executing twice the same workflow in perfect concurrency with the same transactionId and non idempotent and not async but retention time is set", async () => {
const transactionId = "transaction_id" const transactionId = "concurrency_transaction_id"
const workflowId = "workflow_id" + ulid() const workflowId = "concurrency_workflow_id" + ulid()
const step1 = createStep("step1", async () => { const step1 = createStep("step1", async () => {
await setTimeout(100) await setTimeout(100)
@@ -170,10 +381,12 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
) )
const [result1, result2] = await promiseAll([ const [result1, result2] = await promiseAll([
workflowOrcModule.run(workflowId, { workflowOrcModule
input: {}, .run(workflowId, {
transactionId, input: {},
}), transactionId,
})
.catch((e) => e),
workflowOrcModule workflowOrcModule
.run(workflowId, { .run(workflowId, {
input: {}, input: {},
@@ -182,8 +395,8 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
.catch((e) => e), .catch((e) => e),
]) ])
expect(result1.result).toEqual("step1") expect(result1.result || result2.result).toEqual("step1")
expect(result2.message).toEqual( expect(result2.message || result1.message).toEqual(
"Transaction already started for transactionId: " + transactionId "Transaction already started for transactionId: " + transactionId
) )
}) })
@@ -4,6 +4,7 @@ import {
IDistributedSchedulerStorage, IDistributedSchedulerStorage,
IDistributedTransactionStorage, IDistributedTransactionStorage,
SchedulerOptions, SchedulerOptions,
SkipCancelledExecutionError,
SkipExecutionError, SkipExecutionError,
TransactionCheckpoint, TransactionCheckpoint,
TransactionContext, TransactionContext,
@@ -632,6 +633,19 @@ export class RedisDistributedTransactionStorage
throw new SkipExecutionError("Already finished by another execution") throw new SkipExecutionError("Already finished by another execution")
} }
// First ensure that the latest execution was not cancelled, otherwise we skip the execution
const latestTransactionCancelledAt = latestUpdatedFlow.cancelledAt
const currentTransactionCancelledAt = currentFlow.cancelledAt
if (
!!latestTransactionCancelledAt &&
currentTransactionCancelledAt == null
) {
throw new SkipCancelledExecutionError(
"Workflow execution has been cancelled during the execution"
)
}
const currentFlowSteps = Object.values(currentFlow.steps || {}) const currentFlowSteps = Object.values(currentFlow.steps || {})
const latestUpdatedFlowSteps = latestUpdatedFlow.steps const latestUpdatedFlowSteps = latestUpdatedFlow.steps
? Object.values( ? Object.values(