fix: workflow async concurrency (#13769)

* executeAsync

* || 1

* wip

* stepId

* stepId

* wip

* wip

* continue versioning management changes

* fix and improve concurrency

* update in memory engine

* remove duplicated test

* fix script

* Create weak-drinks-confess.md

* fixes

* fix

* fix

* continuation

* centralize merge checkepoint

* centralize merge checkpoint

* fix locking

* rm only

* Continue improvements and fixes

* fixes

* fixes

* hasAwaiting will be recomputed

* fix orchestrator engine

* bump version on async parallel steps only

* mark as delivered fix

* changeset

* check partitions

* avoid saving when having parent step

* cart test

---------

Co-authored-by: Carlos R. L. Rodrigues <rodrigolr@gmail.com>
Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2025-10-20 15:29:19 +02:00
committed by GitHub
parent d97a60d3c1
commit 516f5a3896
31 changed files with 2712 additions and 1406 deletions

View File

@@ -30,6 +30,17 @@ const nestedWorkflow = createWorkflow(
}
)
const nestedWorkflow2 = createWorkflow(
{
name: "nested_sub_flow_async_2",
},
function (input) {
const resp = step_1_background(input)
return resp
}
)
createWorkflow(
{
name: "workflow_async_background",
@@ -41,7 +52,7 @@ createWorkflow(
input,
})
.config({ name: "step_sub_flow_1" }),
nestedWorkflow
nestedWorkflow2
.runAsStep({
input,
})

View File

@@ -14,9 +14,25 @@ const step_2 = createStep(
async (_, { container }) => {
const we = container.resolve(Modules.WORKFLOW_ENGINE)
const onFinishPromise = new Promise<void>((resolve, reject) => {
void we.subscribe({
workflowId: "workflow_sub_workflow",
subscriber: (event) => {
if (event.eventType === "onFinish") {
if (event.errors.length > 0) {
reject(event.errors[0])
} else {
resolve()
}
}
},
})
})
await we.run("workflow_sub_workflow", {
throwOnError: true,
})
await onFinishPromise
}
)
@@ -34,7 +50,6 @@ const step_2_sub = createStep(
const subFlow = createWorkflow(
{
name: "workflow_sub_workflow",
retentionTime: 1000,
},
function (input) {
step_2_sub()
@@ -68,7 +83,7 @@ const step_3 = createStep(
createWorkflow(
{
name: "workflow_parallel_async",
retentionTime: 1000,
retentionTime: 5,
},
function (input) {
parallelize(step_1(), step_2(), step_3())

View File

@@ -1,4 +1,5 @@
import { MedusaContainer } from "@medusajs/framework"
import { asFunction } from "@medusajs/framework/awilix"
import {
DistributedTransactionType,
TransactionState,
@@ -25,7 +26,6 @@ import {
} from "@medusajs/framework/workflows-sdk"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { WorkflowsModuleService } from "@services"
import { asFunction } from "@medusajs/framework/awilix"
import { setTimeout as setTimeoutSync } from "timers"
import { setTimeout as setTimeoutPromise } from "timers/promises"
import { ulid } from "ulid"
@@ -39,40 +39,30 @@ import {
workflowNotIdempotentWithRetentionStep3Invoke,
} from "../__fixtures__"
import {
eventGroupWorkflowId,
workflowEventGroupIdStep1Mock,
workflowEventGroupIdStep2Mock,
} from "../__fixtures__/workflow_event_group_id"
import {
step1InvokeMock as step1InvokeMockAutoRetries,
step2InvokeMock as step2InvokeMockAutoRetries,
step1CompensateMock as step1CompensateMockAutoRetries,
step1InvokeMock as step1InvokeMockAutoRetries,
step2CompensateMock as step2CompensateMockAutoRetries,
step2InvokeMock as step2InvokeMockAutoRetries,
} from "../__fixtures__/workflow_1_auto_retries"
import {
step1InvokeMock as step1InvokeMockAutoRetriesFalse,
step2InvokeMock as step2InvokeMockAutoRetriesFalse,
step1CompensateMock as step1CompensateMockAutoRetriesFalse,
step1InvokeMock as step1InvokeMockAutoRetriesFalse,
step2CompensateMock as step2CompensateMockAutoRetriesFalse,
step2InvokeMock as step2InvokeMockAutoRetriesFalse,
} from "../__fixtures__/workflow_1_auto_retries_false"
import {
step1InvokeMock as step1InvokeMockManualRetry,
step2InvokeMock as step2InvokeMockManualRetry,
} from "../__fixtures__/workflow_1_manual_retry_step"
import {
eventGroupWorkflowId,
workflowEventGroupIdStep1Mock,
workflowEventGroupIdStep2Mock,
} from "../__fixtures__/workflow_event_group_id"
import { createScheduled } from "../__fixtures__/workflow_scheduled"
jest.setTimeout(60000)
const failTrap = (done, name, timeout = 5000) => {
return setTimeoutSync(() => {
// REF:https://stackoverflow.com/questions/78028715/jest-async-test-with-event-emitter-isnt-ending
console.warn(
`Jest is breaking the event emit with its debouncer. This allows to continue the test by managing the timeout of the test manually. ${name}`
)
done()
}, timeout)
}
function times(num) {
let resolver
let counter = 0
@@ -159,14 +149,14 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
})
describe("Cancel transaction", function () {
it("should cancel an ongoing execution with async unfinished yet step", (done) => {
it("should cancel an ongoing execution with async unfinished yet step", async () => {
const transactionId = "transaction-to-cancel-id" + ulid()
const step1 = createStep("step1", async () => {
return new StepResponse("step1")
})
const step2 = createStep("step2", async () => {
await setTimeoutPromise(500)
await setTimeoutPromise(200)
return new StepResponse("step2")
})
@@ -184,43 +174,39 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
return new WorkflowResponse("finished")
})
workflowOrcModule
.run(workflowId, {
input: {},
const onFinish = new Promise<void>((resolve) => {
workflowOrcModule.subscribe({
workflowId,
transactionId,
})
.then(async () => {
await setTimeoutPromise(100)
await workflowOrcModule.cancel(workflowId, {
transactionId,
})
workflowOrcModule.subscribe({
workflowId,
transactionId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
const execution =
await workflowOrcModule.listWorkflowExecutions({
transaction_id: transactionId,
})
expect(execution.length).toEqual(1)
expect(execution[0].state).toEqual(
TransactionState.REVERTED
)
done()
clearTimeout(timeout)
}
},
})
subscriber: async (event) => {
if (event.eventType === "onFinish") {
resolve()
}
},
})
const timeout = failTrap(
done,
"should cancel an ongoing execution with async unfinished yet step"
)
workflowOrcModule
.run(workflowId, {
input: {},
transactionId,
})
.then(async () => {
await setTimeoutPromise(100)
await workflowOrcModule.cancel(workflowId, {
transactionId,
})
})
})
await onFinish
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 () => {
@@ -375,11 +361,11 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
})
})
it("should manually retry a step that is taking too long to finish", (done) => {
it("should manually retry a step that is taking too long to finish", async () => {
const transactionId = "transaction-manual-retry" + ulid()
const workflowId = "workflow_1_manual_retry_step"
void workflowOrcModule
await workflowOrcModule
.run(workflowId, {
input: {},
transactionId,
@@ -388,6 +374,18 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
expect(step1InvokeMockManualRetry).toHaveBeenCalledTimes(1)
expect(step2InvokeMockManualRetry).toHaveBeenCalledTimes(1)
const onFinishPromise = new Promise<void>((resolve, reject) => {
workflowOrcModule.subscribe({
workflowId,
transactionId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
resolve()
}
},
})
})
void workflowOrcModule.retryStep({
idempotencyKey: {
workflowId,
@@ -396,68 +394,54 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
action: "invoke",
},
})
return onFinishPromise
})
workflowOrcModule.subscribe({
workflowId,
transactionId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
expect(step1InvokeMockManualRetry).toHaveBeenCalledTimes(1)
expect(step2InvokeMockManualRetry).toHaveBeenCalledTimes(2)
done()
clearTimeout(timeout)
}
},
})
const timeout = failTrap(
done,
"should manually retry a step that is taking too long to finish"
)
expect(step1InvokeMockManualRetry).toHaveBeenCalledTimes(1)
expect(step2InvokeMockManualRetry).toHaveBeenCalledTimes(2)
})
it("should retry steps X times automatically when maxRetries is set", (done) => {
it("should retry steps X times automatically when maxRetries is set", async () => {
const transactionId = "transaction-auto-retries" + ulid()
const workflowId = "workflow_1_auto_retries"
const onFinishPromise = new Promise<void>((resolve, reject) => {
workflowOrcModule.subscribe({
workflowId,
transactionId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
resolve()
}
},
})
})
void workflowOrcModule.run(workflowId, {
input: {},
transactionId,
})
workflowOrcModule.subscribe({
workflowId,
transactionId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
expect(step1InvokeMockAutoRetries).toHaveBeenCalledTimes(1)
expect(step2InvokeMockAutoRetries).toHaveBeenCalledTimes(3)
expect(step1CompensateMockAutoRetries).toHaveBeenCalledTimes(1)
expect(step2CompensateMockAutoRetries).toHaveBeenCalledTimes(1)
done()
clearTimeout(timeout)
}
},
})
await onFinishPromise
const timeout = failTrap(
done,
"should retry steps X times automatically when maxRetries is set"
)
expect(step1InvokeMockAutoRetries).toHaveBeenCalledTimes(1)
expect(step2InvokeMockAutoRetries).toHaveBeenCalledTimes(3)
expect(step1CompensateMockAutoRetries).toHaveBeenCalledTimes(1)
expect(step2CompensateMockAutoRetries).toHaveBeenCalledTimes(1)
})
it("should not retry steps X times automatically when maxRetries is set and autoRetry is false", (done) => {
;(async () => {
const transactionId = "transaction-auto-retries" + ulid()
const workflowId = "workflow_1_auto_retries_false"
it("should not retry steps X times automatically when maxRetries is set and autoRetry is false", async () => {
const transactionId = "transaction-auto-retries" + ulid()
const workflowId = "workflow_1_auto_retries_false"
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
throwOnError: false,
})
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
throwOnError: false,
})
const onFinishPromise = new Promise<void>((resolve, reject) => {
workflowOrcModule.subscribe({
workflowId,
transactionId,
@@ -471,37 +455,39 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
expect(
step2CompensateMockAutoRetriesFalse
).toHaveBeenCalledTimes(1)
done()
resolve()
}
},
})
})
expect(step1InvokeMockAutoRetriesFalse).toHaveBeenCalledTimes(1)
expect(step2InvokeMockAutoRetriesFalse).toHaveBeenCalledTimes(1)
expect(step1CompensateMockAutoRetriesFalse).toHaveBeenCalledTimes(0)
expect(step2CompensateMockAutoRetriesFalse).toHaveBeenCalledTimes(0)
expect(step1InvokeMockAutoRetriesFalse).toHaveBeenCalledTimes(1)
expect(step2InvokeMockAutoRetriesFalse).toHaveBeenCalledTimes(1)
expect(step1CompensateMockAutoRetriesFalse).toHaveBeenCalledTimes(0)
expect(step2CompensateMockAutoRetriesFalse).toHaveBeenCalledTimes(0)
await setTimeoutPromise(2000)
await setTimeoutPromise(2000)
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
throwOnError: false,
})
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
throwOnError: false,
})
await setTimeoutPromise(2000)
await setTimeoutPromise(2000)
expect(step1InvokeMockAutoRetriesFalse).toHaveBeenCalledTimes(1)
expect(step2InvokeMockAutoRetriesFalse).toHaveBeenCalledTimes(2)
expect(step1CompensateMockAutoRetriesFalse).toHaveBeenCalledTimes(0)
expect(step2CompensateMockAutoRetriesFalse).toHaveBeenCalledTimes(0)
expect(step1InvokeMockAutoRetriesFalse).toHaveBeenCalledTimes(1)
expect(step2InvokeMockAutoRetriesFalse).toHaveBeenCalledTimes(2)
expect(step1CompensateMockAutoRetriesFalse).toHaveBeenCalledTimes(0)
expect(step2CompensateMockAutoRetriesFalse).toHaveBeenCalledTimes(0)
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
throwOnError: false,
})
})()
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
throwOnError: false,
})
await onFinishPromise
})
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 () => {
@@ -611,7 +597,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
)
})
it("should compose nested workflows w/ async steps", (done) => {
it("should compose nested workflows w/ async steps", async () => {
const asyncResults: any[] = []
const mockStep1Fn = jest.fn().mockImplementation(() => {
const res = { obj: "return from 1" }
@@ -662,35 +648,31 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
})
asyncResults.push("begin workflow")
workflowOrcModule
.run(workflowId, {
input: {},
})
.then(() => {
asyncResults.push("returned workflow")
await workflowOrcModule.run(workflowId, {
input: {},
})
void workflowOrcModule.subscribe({
workflowId,
subscriber: (event) => {
if (event.eventType === "onFinish") {
expect(asyncResults).toEqual([
"begin workflow",
{ obj: "return from 1" },
"returned workflow",
{ obj: "return from 2" },
{ obj: "return from 3" },
])
done()
clearTimeout(timeout)
}
},
})
const onFinishPromise = new Promise<void>((resolve) => {
void workflowOrcModule.subscribe({
workflowId,
subscriber: (event) => {
if (event.eventType === "onFinish") {
expect(asyncResults).toEqual([
"begin workflow",
{ obj: "return from 1" },
"returned workflow",
{ obj: "return from 2" },
{ obj: "return from 3" },
])
resolve()
}
},
})
})
const timeout = failTrap(
done,
"should subscribe to a async workflow and receive the response when it finishes"
)
asyncResults.push("returned workflow")
await onFinishPromise
})
describe("Testing basic workflow", function () {
@@ -871,22 +853,19 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
expect(transaction.getFlow().state).toEqual("reverted")
})
it("should subscribe to a async workflow and receive the response when it finishes", (done) => {
it("should subscribe to a async workflow and receive the response when it finishes", async () => {
const transactionId = "trx_123" + ulid()
const onFinish = jest.fn(() => {
done()
clearTimeout(timeout)
})
void workflowOrcModule.subscribe({
workflowId: "workflow_async_background",
transactionId,
subscriber: (event) => {
if (event.eventType === "onFinish") {
onFinish()
}
},
const onFinishPromise = new Promise<void>((resolve) => {
void workflowOrcModule.subscribe({
workflowId: "workflow_async_background",
transactionId,
subscriber: (event) => {
if (event.eventType === "onFinish") {
resolve()
}
},
})
})
void workflowOrcModule.run("workflow_async_background", {
@@ -897,11 +876,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
throwOnError: false,
})
expect(onFinish).toHaveBeenCalledTimes(0)
const timeout = failTrap(
done,
"should subscribe to a async workflow and receive the response when it finishes"
)
await onFinishPromise
})
it("should cancel and revert a completed workflow", async () => {
@@ -955,43 +930,43 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
expect(executions[0].state).toEqual(TransactionState.REVERTED)
})
it("should run conditional steps if condition is true", (done) => {
void workflowOrcModule.subscribe({
workflowId: "workflow_conditional_step",
subscriber: (event) => {
if (event.eventType === "onFinish") {
expect(conditionalStep2Invoke).toHaveBeenCalledTimes(2)
expect(conditionalStep3Invoke).toHaveBeenCalledTimes(1)
done()
clearTimeout(timeout)
}
},
it("should run conditional steps if condition is true", async () => {
let timeout: NodeJS.Timeout
const onFinishPromise = new Promise<void>((resolve, reject) => {
void workflowOrcModule.subscribe({
workflowId: "workflow_conditional_step",
subscriber: (event) => {
if (event.eventType === "onFinish") {
resolve()
}
},
})
})
workflowOrcModule.run("workflow_conditional_step", {
void workflowOrcModule.run("workflow_conditional_step", {
input: {
runNewStepName: true,
},
throwOnError: true,
})
const timeout = failTrap(
done,
"should not run conditional steps if condition is false"
)
await onFinishPromise
expect(conditionalStep2Invoke).toHaveBeenCalledTimes(2)
expect(conditionalStep3Invoke).toHaveBeenCalledTimes(1)
})
it("should not run conditional steps if condition is false", (done) => {
void workflowOrcModule.subscribe({
workflowId: "workflow_conditional_step",
subscriber: (event) => {
if (event.eventType === "onFinish") {
expect(conditionalStep2Invoke).toHaveBeenCalledTimes(1)
expect(conditionalStep3Invoke).toHaveBeenCalledTimes(0)
done()
clearTimeout(timeout)
}
},
it("should not run conditional steps if condition is false", async () => {
const onFinishPromise = new Promise<void>((resolve, reject) => {
void workflowOrcModule.subscribe({
workflowId: "workflow_conditional_step",
subscriber: (event) => {
if (event.eventType === "onFinish") {
resolve()
}
},
})
})
workflowOrcModule.run("workflow_conditional_step", {
@@ -1001,10 +976,10 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
throwOnError: true,
})
const timeout = failTrap(
done,
"should not run conditional steps if condition is false"
)
await onFinishPromise
expect(conditionalStep2Invoke).toHaveBeenCalledTimes(1)
expect(conditionalStep3Invoke).toHaveBeenCalledTimes(0)
})
})
@@ -1127,36 +1102,36 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
expect(executionsListAfter).toHaveLength(1)
})
it("should display error when multple async steps are running in parallel", (done) => {
it("should display error when multple async steps are running in parallel", async () => {
let errors: Error[] = []
const onFinishPromise = new Promise<void>((resolve) => {
void workflowOrcModule.subscribe({
workflowId: "workflow_parallel_async",
subscriber: (event) => {
if (event.eventType === "onFinish") {
errors = event.errors
resolve()
}
},
})
})
void workflowOrcModule.run("workflow_parallel_async", {
input: {},
throwOnError: false,
})
void workflowOrcModule.subscribe({
workflowId: "workflow_parallel_async",
subscriber: (event) => {
if (event.eventType === "onFinish") {
expect(event.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: "step_2",
handlerType: "invoke",
error: expect.objectContaining({
message: "Error in parallel step",
}),
}),
])
)
done()
clearTimeout(timeout)
}
},
})
await onFinishPromise
const timeout = failTrap(
done,
"should display error when multple async steps are running in parallel"
const errMessage = errors[0]?.error.message
expect(errMessage).toContain("Error in parallel step")
expect(errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: "step_2",
handlerType: "invoke",
}),
])
)
})
})

View File

@@ -1,36 +1,187 @@
import { IWorkflowEngineService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { Modules, TransactionHandlerType } from "@medusajs/framework/utils"
import {
createStep,
createWorkflow,
parallelize,
StepResponse,
transform,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { setTimeout as setTimeoutSync } from "timers"
import { setTimeout } from "timers/promises"
import { ulid } from "ulid"
import "../__fixtures__"
jest.setTimeout(300000)
const failTrap = (done, name, timeout = 5000) => {
return setTimeoutSync(() => {
// REF:https://stackoverflow.com/questions/78028715/jest-async-test-with-event-emitter-isnt-ending
console.warn(
`Jest is breaking the event emit with its debouncer. This allows to continue the test by managing the timeout of the test manually. ${name}`
)
done()
}, timeout)
}
jest.setTimeout(30000)
moduleIntegrationTestRunner<IWorkflowEngineService>({
moduleName: Modules.WORKFLOW_ENGINE,
resolve: __dirname + "/../..",
testSuite: ({ service: workflowOrcModule, medusaApp }) => {
testSuite: ({ service: workflowOrcModule }) => {
// TODO: Debug the issue with this test https://github.com/medusajs/medusa/actions/runs/13900190144/job/38897122803#step:5:5616
describe.skip("Testing race condition of the workflow during retry", () => {
it("should prevent race continuation of the workflow during retryIntervalAwaiting in background execution", (done) => {
describe("Testing race condition of the workflow during retry", () => {
it("should manage saving multiple async steps in concurrency", async () => {
const step0 = createStep(
{ name: "step0", async: true, backgroundExecution: true },
async () => {
return new StepResponse("result from step 0")
}
)
const step1 = createStep(
{ name: "step1", async: true, backgroundExecution: true },
async () => {
return new StepResponse("result from step 1")
}
)
const step2 = createStep(
{ name: "step2", async: true, backgroundExecution: true },
async () => {
return new StepResponse("result from step 2")
}
)
const step3 = createStep(
{ name: "step3", async: true, backgroundExecution: true },
async () => {
return new StepResponse("result from step 3")
}
)
const step4 = createStep(
{ name: "step4", async: true, backgroundExecution: true },
async () => {
return new StepResponse("result from step 4")
}
)
const step5 = createStep({ name: "step5" }, async (all: string[]) => {
const ret = [...all, "result from step 5"]
return new StepResponse(ret)
})
const workflowId = "workflow-1" + ulid()
createWorkflow(
{
name: workflowId,
idempotent: true,
retentionTime: 5,
},
function () {
const all = parallelize(step0(), step1(), step2(), step3(), step4())
const res = step5(all)
return new WorkflowResponse(res)
}
)
const transactionId = ulid()
const done = new Promise<void>((resolve, reject) => {
void workflowOrcModule.subscribe({
workflowId: workflowId,
transactionId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
resolve(event.result)
}
},
})
})
await workflowOrcModule.run(workflowId, {
throwOnError: false,
logOnError: true,
transactionId,
})
const result = await done
expect(result).toEqual([
"result from step 0",
"result from step 1",
"result from step 2",
"result from step 3",
"result from step 4",
"result from step 5",
])
})
it("should manage saving multiple async steps in concurrency without background execution while setting steps as success manually concurrently", async () => {
const step0 = createStep({ name: "step0", async: true }, async () => {})
const step1 = createStep({ name: "step1", async: true }, async () => {})
const step2 = createStep({ name: "step2", async: true }, async () => {})
const step3 = createStep({ name: "step3", async: true }, async () => {})
const step4 = createStep({ name: "step4", async: true }, async () => {})
const step5 = createStep({ name: "step5" }, async (all: any[]) => {
const ret = [...all, "result from step 5"]
return new StepResponse(ret)
})
const workflowId = "workflow-1" + ulid()
createWorkflow(
{
name: workflowId,
idempotent: true,
retentionTime: 1,
},
function () {
const all = parallelize(step0(), step1(), step2(), step3(), step4())
const res = step5(all)
return new WorkflowResponse(res)
}
)
const transactionId = ulid()
const done = new Promise<void>((resolve, reject) => {
void workflowOrcModule.subscribe({
workflowId: workflowId,
transactionId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
resolve(event.result)
}
},
})
})
await workflowOrcModule.run(workflowId, {
throwOnError: false,
logOnError: true,
transactionId,
})
await setTimeout(100) // Just to wait a bit before firering everything
for (let i = 0; i <= 4; i++) {
void workflowOrcModule.setStepSuccess({
idempotencyKey: {
workflowId: workflowId,
transactionId: transactionId,
stepId: `step${i}`,
action: TransactionHandlerType.INVOKE,
},
stepResponse: new StepResponse("result from step " + i),
})
}
const res = await done
expect(res).toEqual([
"result from step 0",
"result from step 1",
"result from step 2",
"result from step 3",
"result from step 4",
"result from step 5",
])
})
it("should prevent race continuation of the workflow during retryIntervalAwaiting in background execution", async () => {
const transactionId = "transaction_id" + ulid()
const workflowId = "RACE_workflow-1" + ulid()
const step0InvokeMock = jest.fn()
const step1InvokeMock = jest.fn()
const step2InvokeMock = jest.fn()
@@ -43,7 +194,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
const step1 = createStep("step1", async (_) => {
step1InvokeMock()
await setTimeout(2000)
await setTimeout(200)
return new StepResponse({ isSuccess: true })
})
@@ -57,57 +208,67 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
return new WorkflowResponse(status)
})
createWorkflow("workflow-1", function () {
const build = step0()
const status = subWorkflow.runAsStep({} as any).config({
async: true,
compensateAsync: true,
backgroundExecution: true,
retryIntervalAwaiting: 1,
})
const transformedResult = transform({ status }, (data) => {
transformMock()
return {
status: data.status,
}
})
step2(transformedResult)
return new WorkflowResponse(build)
})
void workflowOrcModule.subscribe({
workflowId: "workflow-1",
subscriber: (event) => {
if (event.eventType === "onFinish") {
expect(step0InvokeMock).toHaveBeenCalledTimes(1)
expect(step1InvokeMock.mock.calls.length).toBeGreaterThan(1)
expect(step2InvokeMock).toHaveBeenCalledTimes(1)
expect(transformMock).toHaveBeenCalledTimes(1)
done()
clearTimeout(timeout)
}
createWorkflow(
{
name: workflowId,
idempotent: true,
retentionTime: 5,
},
function () {
const build = step0()
const status = subWorkflow.runAsStep({} as any).config({
async: true,
compensateAsync: true,
backgroundExecution: true,
retryIntervalAwaiting: 0.1,
})
const transformedResult = transform({ status }, (data) => {
transformMock()
return {
status: data.status,
}
})
step2(transformedResult)
return new WorkflowResponse(build)
}
)
const onFinish = new Promise<void>((resolve) => {
void workflowOrcModule.subscribe({
workflowId,
transactionId,
subscriber: (event) => {
if (event.eventType === "onFinish") {
resolve()
}
},
})
})
workflowOrcModule
.run("workflow-1", { throwOnError: false })
.run(workflowId, {
transactionId,
throwOnError: false,
logOnError: true,
})
.then(({ result }) => {
expect(result).toBe("result from step 0")
})
.catch((e) => e)
const timeout = failTrap(
done,
"should prevent race continuation of the workflow during retryIntervalAwaiting in background execution"
)
await onFinish
expect(step0InvokeMock).toHaveBeenCalledTimes(1)
expect(step1InvokeMock.mock.calls.length).toBeGreaterThan(1)
expect(step2InvokeMock).toHaveBeenCalledTimes(1)
expect(transformMock).toHaveBeenCalledTimes(1)
})
it("should prevent race continuation of the workflow compensation during retryIntervalAwaiting in background execution", (done) => {
const workflowId = "RACE_workflow-1"
it("should prevent race continuation of the workflow compensation during retryIntervalAwaiting in background execution", async () => {
const transactionId = "transaction_id" + ulid()
const workflowId = "RACE_workflow-1" + ulid()
const step0InvokeMock = jest.fn()
const step0CompensateMock = jest.fn()
@@ -131,7 +292,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
"RACE_step1",
async (_) => {
step1InvokeMock()
await setTimeout(300)
await setTimeout(1000)
throw new Error("error from step 1")
},
() => {
@@ -149,56 +310,63 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
return new WorkflowResponse(status)
})
createWorkflow(workflowId, function () {
const build = step0()
const status = subWorkflow.runAsStep({} as any).config({
async: true,
compensateAsync: true,
backgroundExecution: true,
retryIntervalAwaiting: 0.1,
})
const transformedResult = transform({ status }, (data) => {
transformMock()
return {
status: data.status,
}
})
step2(transformedResult)
return new WorkflowResponse(build)
})
void workflowOrcModule.subscribe({
workflowId: workflowId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
expect(step0InvokeMock).toHaveBeenCalledTimes(1)
expect(step0CompensateMock).toHaveBeenCalledTimes(1)
expect(step1InvokeMock.mock.calls.length).toBeGreaterThan(2)
expect(step1CompensateMock).toHaveBeenCalledTimes(1)
expect(step2InvokeMock).toHaveBeenCalledTimes(0)
expect(transformMock).toHaveBeenCalledTimes(0)
done()
clearTimeout(timeout)
}
createWorkflow(
{
name: workflowId,
},
function () {
const build = step0()
const status = subWorkflow.runAsStep({} as any).config({
async: true,
compensateAsync: true,
backgroundExecution: true,
retryIntervalAwaiting: 0.1,
maxAwaitingRetries: 3,
})
const transformedResult = transform({ status }, (data) => {
transformMock()
return {
status: data.status,
}
})
step2(transformedResult)
return new WorkflowResponse(build)
}
)
const onFinish = new Promise<void>((resolve) => {
void workflowOrcModule.subscribe({
workflowId,
transactionId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
resolve()
}
},
})
})
workflowOrcModule
await workflowOrcModule
.run(workflowId, {
transactionId,
throwOnError: false,
logOnError: true,
})
.then(({ result }) => {
expect(result).toBe("result from step 0")
})
.catch((e) => e)
const timeout = failTrap(
done,
"should prevent race continuation of the workflow compensation during retryIntervalAwaiting in background execution"
)
await onFinish
expect(step0InvokeMock).toHaveBeenCalledTimes(1)
expect(step0CompensateMock).toHaveBeenCalledTimes(1)
expect(step1InvokeMock).toHaveBeenCalledTimes(3)
expect(step1CompensateMock.mock.calls.length).toBeGreaterThan(0)
expect(step2InvokeMock).toHaveBeenCalledTimes(0)
expect(transformMock).toHaveBeenCalledTimes(0)
})
})
},

View File

@@ -0,0 +1,185 @@
import { IWorkflowEngineService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import {
createStep,
createWorkflow,
StepResponse,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { setTimeout as setTimeoutSync } from "timers"
import { setTimeout as setTimeoutPromise } from "timers/promises"
import { ulid } from "ulid"
import "../__fixtures__"
jest.setTimeout(60000)
moduleIntegrationTestRunner<IWorkflowEngineService>({
moduleName: Modules.WORKFLOW_ENGINE,
resolve: __dirname + "/../..",
testSuite: ({ service: workflowOrcModule }) => {
describe("Workflow Orchestrator module subscribe", function () {
it("should subscribe to a workflow and receive the response when it finishes", async () => {
const step1 = createStep({ name: "step1" }, async () => {
return new StepResponse("step1")
})
const step2 = createStep({ name: "step2" }, async () => {
await setTimeoutPromise(1000)
return new StepResponse("step2")
})
const workflowId = "workflow" + ulid()
createWorkflow(workflowId, function (input) {
step1()
step2().config({
async: true,
})
return new WorkflowResponse("workflow")
})
const step1_1 = createStep({ name: "step1_1" }, async () => {
return new StepResponse("step1_1")
})
const step2_1 = createStep({ name: "step2_1" }, async () => {
await setTimeoutPromise(1000)
return new StepResponse("step2_1")
})
const workflow2Id = "workflow_2" + ulid()
createWorkflow(workflow2Id, function (input) {
step1_1()
step2_1().config({
async: true,
})
return new WorkflowResponse("workflow_2")
})
const transactionId = "trx_123" + ulid()
const transactionId2 = "trx_124" + ulid()
const onWorkflowFinishSpy = jest.fn()
const onWorkflowFinishPromise = new Promise<void>((resolve) => {
void workflowOrcModule.subscribe({
workflowId: workflowId,
transactionId,
subscriber: (event) => {
if (event.eventType === "onFinish") {
onWorkflowFinishSpy()
workflowOrcModule.run(workflow2Id, {
transactionId: transactionId2,
})
resolve()
}
},
})
})
const onWorkflow2FinishSpy = jest.fn()
const workflow2FinishPromise = new Promise<void>((resolve) => {
void workflowOrcModule.subscribe({
workflowId: workflow2Id,
subscriber: (event) => {
if (event.eventType === "onFinish") {
onWorkflow2FinishSpy()
resolve()
}
},
})
})
workflowOrcModule.run(workflowId, {
transactionId,
})
await onWorkflowFinishPromise
await workflow2FinishPromise
expect(onWorkflowFinishSpy).toHaveBeenCalledTimes(1)
expect(onWorkflow2FinishSpy).toHaveBeenCalledTimes(1)
})
it("should subscribe to a workflow and receive the response when it finishes (2)", async () => {
const step1 = createStep({ name: "step1" }, async () => {
return new StepResponse("step1")
})
const step2 = createStep({ name: "step2" }, async () => {
await setTimeoutPromise(1000)
return new StepResponse("step2")
})
const workflowId = "workflow" + ulid()
createWorkflow(workflowId, function (input) {
step1()
step2().config({
async: true,
})
return new WorkflowResponse("workflow")
})
const step1_1 = createStep({ name: "step1_1" }, async () => {
return new StepResponse("step1_1")
})
const step2_1 = createStep({ name: "step2_1" }, async () => {
await setTimeoutPromise(1000)
return new StepResponse("step2_1")
})
const workflow2Id = "workflow_2" + ulid()
createWorkflow(workflow2Id, function (input) {
step1_1()
step2_1().config({
async: true,
})
return new WorkflowResponse("workflow_2")
})
const transactionId = "trx_123" + ulid()
const transactionId2 = "trx_124" + ulid()
const onWorkflowFinishSpy = jest.fn()
const onWorkflowFinishPromise = new Promise<void>((resolve) => {
void workflowOrcModule.subscribe({
workflowId: workflowId,
transactionId,
subscriber: (event) => {
if (event.eventType === "onFinish") {
onWorkflowFinishSpy()
workflowOrcModule.run(workflow2Id, {
transactionId: transactionId2,
})
resolve()
}
},
})
})
const onWorkflow2FinishSpy = jest.fn()
const workflow2FinishPromise = new Promise<void>((resolve) => {
void workflowOrcModule.subscribe({
workflowId: workflow2Id,
subscriber: (event) => {
if (event.eventType === "onFinish") {
onWorkflow2FinishSpy()
resolve()
}
},
})
})
workflowOrcModule.run(workflowId, {
transactionId,
})
await onWorkflowFinishPromise
await workflow2FinishPromise
expect(onWorkflowFinishSpy).toHaveBeenCalledTimes(1)
expect(onWorkflow2FinishSpy).toHaveBeenCalledTimes(1)
})
})
},
})