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:
committed by
GitHub
parent
d97a60d3c1
commit
516f5a3896
+12
-1
@@ -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,
|
||||
})
|
||||
|
||||
+17
-2
@@ -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())
|
||||
|
||||
+201
-226
@@ -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",
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+267
-99
@@ -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)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
+185
@@ -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)
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -29,7 +29,7 @@
|
||||
"resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json",
|
||||
"build": "rimraf dist && tsc --build && npm run resolve:aliases",
|
||||
"test": "jest --passWithNoTests --bail --forceExit -- src",
|
||||
"test:integration": "jest --forceExit -- integration-tests/**/__tests__/*.ts",
|
||||
"test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.spec.ts",
|
||||
"migration:initial": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:create --initial",
|
||||
"migration:create": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:create",
|
||||
"migration:up": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:up",
|
||||
|
||||
@@ -100,7 +100,7 @@ type Subscribers = Map<WorkflowId, TransactionSubscribers>
|
||||
const AnySubscriber = "any"
|
||||
|
||||
export class WorkflowOrchestratorService {
|
||||
private subscribers: Subscribers = new Map()
|
||||
private static subscribers: Subscribers = new Map()
|
||||
private container_: MedusaContainer
|
||||
private inMemoryDistributedTransactionStorage_: InMemoryDistributedTransactionStorage
|
||||
readonly #logger: Logger
|
||||
@@ -110,7 +110,6 @@ export class WorkflowOrchestratorService {
|
||||
sharedContainer,
|
||||
}: {
|
||||
inMemoryDistributedTransactionStorage: InMemoryDistributedTransactionStorage
|
||||
workflowOrchestratorService: WorkflowOrchestratorService
|
||||
sharedContainer: MedusaContainer
|
||||
}) {
|
||||
this.container_ = sharedContainer
|
||||
@@ -133,9 +132,18 @@ export class WorkflowOrchestratorService {
|
||||
await this.inMemoryDistributedTransactionStorage_.onApplicationShutdown()
|
||||
}
|
||||
|
||||
private async triggerParentStep(transaction, result) {
|
||||
private async triggerParentStep(transaction, result, errors) {
|
||||
const metadata = transaction.flow.metadata
|
||||
const { parentStepIdempotencyKey } = metadata ?? {}
|
||||
const { parentStepIdempotencyKey, cancelingFromParentStep } = metadata ?? {}
|
||||
|
||||
if (cancelingFromParentStep) {
|
||||
/**
|
||||
* If the sub workflow is cancelling from a parent step, we don't want to trigger the parent
|
||||
* step.
|
||||
*/
|
||||
return
|
||||
}
|
||||
|
||||
if (parentStepIdempotencyKey) {
|
||||
const hasFailed = [
|
||||
TransactionState.REVERTED,
|
||||
@@ -145,12 +153,18 @@ export class WorkflowOrchestratorService {
|
||||
if (hasFailed) {
|
||||
await this.setStepFailure({
|
||||
idempotencyKey: parentStepIdempotencyKey,
|
||||
stepResponse: result,
|
||||
stepResponse: errors,
|
||||
options: {
|
||||
logOnError: true,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await this.setStepSuccess({
|
||||
idempotencyKey: parentStepIdempotencyKey,
|
||||
stepResponse: result,
|
||||
options: {
|
||||
logOnError: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -237,7 +251,7 @@ export class WorkflowOrchestratorService {
|
||||
errors,
|
||||
})
|
||||
|
||||
await this.triggerParentStep(ret.transaction, result)
|
||||
await this.triggerParentStep(ret.transaction, result, errors)
|
||||
}
|
||||
|
||||
if (throwOnError && (ret.thrownError || ret.errors?.length)) {
|
||||
@@ -349,7 +363,7 @@ export class WorkflowOrchestratorService {
|
||||
errors,
|
||||
})
|
||||
|
||||
await this.triggerParentStep(ret.transaction, result)
|
||||
await this.triggerParentStep(ret.transaction, result, errors)
|
||||
}
|
||||
|
||||
if (throwOnError && (ret.thrownError || ret.errors?.length)) {
|
||||
@@ -443,7 +457,7 @@ export class WorkflowOrchestratorService {
|
||||
errors,
|
||||
})
|
||||
|
||||
await this.triggerParentStep(ret.transaction, result)
|
||||
await this.triggerParentStep(ret.transaction, result, errors)
|
||||
}
|
||||
|
||||
if (throwOnError && (ret.thrownError || ret.errors?.length)) {
|
||||
@@ -514,7 +528,7 @@ export class WorkflowOrchestratorService {
|
||||
errors,
|
||||
})
|
||||
|
||||
await this.triggerParentStep(ret.transaction, result)
|
||||
await this.triggerParentStep(ret.transaction, result, errors)
|
||||
}
|
||||
|
||||
if (throwOnError && (ret.thrownError || ret.errors?.length)) {
|
||||
@@ -587,7 +601,7 @@ export class WorkflowOrchestratorService {
|
||||
errors,
|
||||
})
|
||||
|
||||
await this.triggerParentStep(ret.transaction, result)
|
||||
await this.triggerParentStep(ret.transaction, result, errors)
|
||||
}
|
||||
|
||||
if (throwOnError && (ret.thrownError || ret.errors?.length)) {
|
||||
@@ -608,7 +622,8 @@ export class WorkflowOrchestratorService {
|
||||
subscriberId,
|
||||
}: SubscribeOptions) {
|
||||
subscriber._id = subscriberId
|
||||
const subscribers = this.subscribers.get(workflowId) ?? new Map()
|
||||
const subscribers =
|
||||
WorkflowOrchestratorService.subscribers.get(workflowId) ?? new Map()
|
||||
|
||||
const handlerIndex = (handlers) => {
|
||||
return handlers.findIndex(
|
||||
@@ -625,7 +640,7 @@ export class WorkflowOrchestratorService {
|
||||
|
||||
transactionSubscribers.push(subscriber)
|
||||
subscribers.set(transactionId, transactionSubscribers)
|
||||
this.subscribers.set(workflowId, subscribers)
|
||||
WorkflowOrchestratorService.subscribers.set(workflowId, subscribers)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -637,7 +652,7 @@ export class WorkflowOrchestratorService {
|
||||
|
||||
workflowSubscribers.push(subscriber)
|
||||
subscribers.set(AnySubscriber, workflowSubscribers)
|
||||
this.subscribers.set(workflowId, subscribers)
|
||||
WorkflowOrchestratorService.subscribers.set(workflowId, subscribers)
|
||||
}
|
||||
|
||||
unsubscribe({
|
||||
@@ -645,7 +660,8 @@ export class WorkflowOrchestratorService {
|
||||
transactionId,
|
||||
subscriberOrId,
|
||||
}: UnsubscribeOptions) {
|
||||
const subscribers = this.subscribers.get(workflowId) ?? new Map()
|
||||
const subscribers =
|
||||
WorkflowOrchestratorService.subscribers.get(workflowId) ?? new Map()
|
||||
|
||||
const filterSubscribers = (handlers: SubscriberHandler[]) => {
|
||||
return handlers.filter((handler) => {
|
||||
@@ -665,7 +681,7 @@ export class WorkflowOrchestratorService {
|
||||
} else {
|
||||
subscribers.delete(transactionId)
|
||||
}
|
||||
this.subscribers.set(workflowId, subscribers)
|
||||
WorkflowOrchestratorService.subscribers.set(workflowId, subscribers)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -676,7 +692,7 @@ export class WorkflowOrchestratorService {
|
||||
} else {
|
||||
subscribers.delete(AnySubscriber)
|
||||
}
|
||||
this.subscribers.set(workflowId, subscribers)
|
||||
WorkflowOrchestratorService.subscribers.set(workflowId, subscribers)
|
||||
}
|
||||
|
||||
private notify(options: NotifyOptions) {
|
||||
@@ -687,7 +703,7 @@ export class WorkflowOrchestratorService {
|
||||
private async processSubscriberNotifications(options: NotifyOptions) {
|
||||
const { workflowId, transactionId, eventType } = options
|
||||
const subscribers: TransactionSubscribers =
|
||||
this.subscribers.get(workflowId) ?? new Map()
|
||||
WorkflowOrchestratorService.subscribers.get(workflowId) ?? new Map()
|
||||
|
||||
const notifySubscribersAsync = async (handlers: SubscriberHandler[]) => {
|
||||
const promises = handlers.map(async (handler) => {
|
||||
|
||||
+141
-240
@@ -20,10 +20,10 @@ import {
|
||||
ModulesSdkTypes,
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
isPresent,
|
||||
MedusaError,
|
||||
TransactionState,
|
||||
TransactionStepState,
|
||||
isPresent,
|
||||
} from "@medusajs/framework/utils"
|
||||
import { WorkflowOrchestratorService } from "@services"
|
||||
import { type CronExpression, parseExpression } from "cron-parser"
|
||||
@@ -31,6 +31,23 @@ import { WorkflowExecution } from "../models/workflow-execution"
|
||||
|
||||
const THIRTY_MINUTES_IN_MS = 1000 * 60 * 30
|
||||
|
||||
const doneStates = [
|
||||
TransactionStepState.DONE,
|
||||
TransactionStepState.REVERTED,
|
||||
TransactionStepState.FAILED,
|
||||
TransactionStepState.SKIPPED,
|
||||
TransactionStepState.SKIPPED_FAILURE,
|
||||
TransactionStepState.TIMEOUT,
|
||||
]
|
||||
|
||||
const finishedStates = [
|
||||
TransactionState.DONE,
|
||||
TransactionState.FAILED,
|
||||
TransactionState.REVERTED,
|
||||
]
|
||||
|
||||
const failedStates = [TransactionState.FAILED, TransactionState.REVERTED]
|
||||
|
||||
function calculateDelayFromExpression(expression: CronExpression): number {
|
||||
const nextTime = expression.next().getTime()
|
||||
const now = Date.now()
|
||||
@@ -71,24 +88,6 @@ function parseNextExecution(
|
||||
return result
|
||||
}
|
||||
|
||||
const invokingStatesSet = new Set([
|
||||
TransactionStepState.INVOKING,
|
||||
TransactionStepState.NOT_STARTED,
|
||||
])
|
||||
|
||||
const compensatingStatesSet = new Set([
|
||||
TransactionStepState.COMPENSATING,
|
||||
TransactionStepState.NOT_STARTED,
|
||||
])
|
||||
|
||||
function isInvokingState(step: TransactionStep) {
|
||||
return invokingStatesSet.has(step.invoke?.state)
|
||||
}
|
||||
|
||||
function isCompensatingState(step: TransactionStep) {
|
||||
return compensatingStatesSet.has(step.compensate?.state)
|
||||
}
|
||||
|
||||
export class InMemoryDistributedTransactionStorage
|
||||
implements IDistributedTransactionStorage, IDistributedSchedulerStorage
|
||||
{
|
||||
@@ -96,8 +95,7 @@ export class InMemoryDistributedTransactionStorage
|
||||
private logger_: Logger
|
||||
private workflowOrchestratorService_: WorkflowOrchestratorService
|
||||
|
||||
private storage: Map<string, Omit<TransactionCheckpoint, "context">> =
|
||||
new Map()
|
||||
private storage: Record<string, TransactionCheckpoint> = {}
|
||||
private scheduled: Map<
|
||||
string,
|
||||
{
|
||||
@@ -112,6 +110,7 @@ export class InMemoryDistributedTransactionStorage
|
||||
private pendingTimers: Set<NodeJS.Timeout> = new Set()
|
||||
|
||||
private clearTimeout_: NodeJS.Timeout
|
||||
private isLocked: Map<string, boolean> = new Map()
|
||||
|
||||
constructor({
|
||||
workflowExecutionService,
|
||||
@@ -179,29 +178,11 @@ export class InMemoryDistributedTransactionStorage
|
||||
|
||||
private async saveToDb(data: TransactionCheckpoint, retentionTime?: number) {
|
||||
const isNotStarted = data.flow.state === TransactionState.NOT_STARTED
|
||||
const isFinished = [
|
||||
TransactionState.DONE,
|
||||
TransactionState.FAILED,
|
||||
TransactionState.REVERTED,
|
||||
].includes(data.flow.state)
|
||||
const asyncVersion = data.flow._v
|
||||
const isFinished = finishedStates.includes(data.flow.state)
|
||||
const isWaitingToCompensate =
|
||||
data.flow.state === TransactionState.WAITING_TO_COMPENSATE
|
||||
|
||||
/**
|
||||
* Bit of explanation:
|
||||
*
|
||||
* When a workflow run, it run all sync step in memory until it reaches a async step.
|
||||
* In that case, it might handover to another process to continue the execution. Thats why
|
||||
* we need to save the current state of the flow. Then from there, it will run again all
|
||||
* sync steps until the next async step. an so on so forth.
|
||||
*
|
||||
* To summarize, we only trully need to save the data when we are reaching any steps that
|
||||
* trigger a handover to a potential other process.
|
||||
*
|
||||
* This allows us to spare some resources and time by not over communicating with the external
|
||||
* database when it is not really needed
|
||||
*/
|
||||
|
||||
const isFlowInvoking = data.flow.state === TransactionState.INVOKING
|
||||
|
||||
const stepsArray = Object.values(data.flow.steps) as TransactionStep[]
|
||||
@@ -240,7 +221,8 @@ export class InMemoryDistributedTransactionStorage
|
||||
|
||||
if (
|
||||
!(isNotStarted || isFinished || isWaitingToCompensate) &&
|
||||
!currentStepsIsAsync
|
||||
!currentStepsIsAsync &&
|
||||
!asyncVersion
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -295,15 +277,14 @@ export class InMemoryDistributedTransactionStorage
|
||||
.catch(() => undefined)
|
||||
|
||||
if (trx) {
|
||||
const { flow, errors } = this.storage.get(key) ?? {}
|
||||
const { flow, errors } = this.storage[key]
|
||||
? JSON.parse(JSON.stringify(this.storage[key]))
|
||||
: {}
|
||||
const { idempotent } = options ?? {}
|
||||
const execution = trx.execution as TransactionFlow
|
||||
|
||||
if (!idempotent) {
|
||||
const isFailedOrReverted = [
|
||||
TransactionState.REVERTED,
|
||||
TransactionState.FAILED,
|
||||
].includes(execution.state)
|
||||
const isFailedOrReverted = failedStates.includes(execution.state)
|
||||
|
||||
const isDone = execution.state === TransactionState.DONE
|
||||
|
||||
@@ -321,11 +302,11 @@ export class InMemoryDistributedTransactionStorage
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
flow: flow ?? (trx.execution as TransactionFlow),
|
||||
context: trx.context?.data as TransactionContext,
|
||||
errors: errors ?? (trx.context?.errors as TransactionStepError[]),
|
||||
}
|
||||
return new TransactionCheckpoint(
|
||||
flow ?? (trx?.execution as TransactionFlow),
|
||||
trx?.context?.data as TransactionContext,
|
||||
errors ?? (trx?.context?.errors as TransactionStepError[])
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
@@ -336,68 +317,94 @@ export class InMemoryDistributedTransactionStorage
|
||||
data: TransactionCheckpoint,
|
||||
ttl?: number,
|
||||
options?: TransactionOptions
|
||||
): Promise<void> {
|
||||
/**
|
||||
* 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)
|
||||
|
||||
const { retentionTime } = options ?? {}
|
||||
|
||||
await this.#preventRaceConditionExecutionIfNecessary({
|
||||
data,
|
||||
key,
|
||||
options,
|
||||
})
|
||||
|
||||
// Only store retention time if it's provided
|
||||
if (retentionTime) {
|
||||
Object.assign(data, {
|
||||
retention_time: retentionTime,
|
||||
})
|
||||
): Promise<TransactionCheckpoint> {
|
||||
if (this.isLocked.has(key)) {
|
||||
throw new Error("Transaction storage is locked")
|
||||
}
|
||||
|
||||
// Store in memory
|
||||
const isNotStarted = data.flow.state === TransactionState.NOT_STARTED
|
||||
const isManualTransactionId = !data.flow.transactionId.startsWith("auto-")
|
||||
this.isLocked.set(key, true)
|
||||
|
||||
if (isNotStarted && isManualTransactionId) {
|
||||
const storedData = this.storage.get(key)
|
||||
if (storedData) {
|
||||
throw new SkipExecutionError(
|
||||
"Transaction already started for transactionId: " +
|
||||
data.flow.transactionId
|
||||
)
|
||||
try {
|
||||
/**
|
||||
* 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 { retentionTime } = options ?? {}
|
||||
|
||||
const hasFinished = finishedStates.includes(data.flow.state)
|
||||
|
||||
let cachedCheckpoint: TransactionCheckpoint | undefined
|
||||
const getCheckpoint = async (options?: TransactionOptions) => {
|
||||
if (!cachedCheckpoint) {
|
||||
cachedCheckpoint = await this.get(key, options)
|
||||
}
|
||||
return cachedCheckpoint
|
||||
}
|
||||
}
|
||||
|
||||
const { flow, errors } = data
|
||||
this.storage.set(key, {
|
||||
flow,
|
||||
errors,
|
||||
})
|
||||
await this.#preventRaceConditionExecutionIfNecessary({
|
||||
data,
|
||||
key,
|
||||
options,
|
||||
getCheckpoint,
|
||||
})
|
||||
|
||||
// Optimize DB operations - only perform when necessary
|
||||
if (hasFinished) {
|
||||
if (!retentionTime) {
|
||||
// If the workflow is nested, we cant just remove it because it would break the compensation algorithm. Instead, it will get deleted when the top level parent is deleted.
|
||||
if (!flow.metadata?.parentStepIdempotencyKey) {
|
||||
await this.deleteFromDb(data)
|
||||
// Only store retention time if it's provided
|
||||
if (retentionTime) {
|
||||
Object.assign(data, {
|
||||
retention_time: retentionTime,
|
||||
})
|
||||
}
|
||||
|
||||
// Store in memory
|
||||
const isNotStarted = data.flow.state === TransactionState.NOT_STARTED
|
||||
const isManualTransactionId = !data.flow.transactionId.startsWith("auto-")
|
||||
|
||||
if (isNotStarted && isManualTransactionId) {
|
||||
const storedData = this.storage[key]
|
||||
if (storedData) {
|
||||
throw new SkipExecutionError(
|
||||
"Transaction already started for transactionId: " +
|
||||
data.flow.transactionId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.flow._v) {
|
||||
const storedData = await this.get(key, {
|
||||
isCancelling: !!data.flow.cancelledAt,
|
||||
} as any)
|
||||
|
||||
TransactionCheckpoint.mergeCheckpoints(data, storedData)
|
||||
}
|
||||
|
||||
const { flow, errors } = data
|
||||
|
||||
this.storage[key] = {
|
||||
flow,
|
||||
context: {} as TransactionContext,
|
||||
errors,
|
||||
} as TransactionCheckpoint
|
||||
|
||||
// Optimize DB operations - only perform when necessary
|
||||
if (hasFinished) {
|
||||
if (!retentionTime) {
|
||||
if (!flow.metadata?.parentStepIdempotencyKey) {
|
||||
await this.deleteFromDb(data)
|
||||
} else {
|
||||
await this.saveToDb(data, retentionTime)
|
||||
}
|
||||
} else {
|
||||
await this.saveToDb(data, retentionTime)
|
||||
}
|
||||
|
||||
delete this.storage[key]
|
||||
} else {
|
||||
await this.saveToDb(data, retentionTime)
|
||||
}
|
||||
|
||||
this.storage.delete(key)
|
||||
} else {
|
||||
await this.saveToDb(data, retentionTime)
|
||||
return data
|
||||
} finally {
|
||||
this.isLocked.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,28 +412,25 @@ export class InMemoryDistributedTransactionStorage
|
||||
data,
|
||||
key,
|
||||
options,
|
||||
getCheckpoint,
|
||||
}: {
|
||||
data: TransactionCheckpoint
|
||||
key: string
|
||||
options?: TransactionOptions
|
||||
getCheckpoint: (
|
||||
options: TransactionOptions
|
||||
) => Promise<TransactionCheckpoint | undefined>
|
||||
}) {
|
||||
// TODO: comment, we have been able to try to replace this entire function
|
||||
// with a locking first approach. We might come back to that another time.
|
||||
// This remove the necessity of all the below logic to prevent race conditions
|
||||
// by preventing the exact same execution to run at the same time.
|
||||
// See early commits from: https://github.com/medusajs/medusa/pull/13345/commits
|
||||
|
||||
const isInitialCheckpoint = [TransactionState.NOT_STARTED].includes(
|
||||
data.flow.state
|
||||
)
|
||||
|
||||
/**
|
||||
* In case many execution can succeed simultaneously, we need to ensure that the latest
|
||||
* execution does continue if a previous execution is considered finished
|
||||
*/
|
||||
const currentFlow = data.flow
|
||||
|
||||
const rawData = this.storage.get(key)
|
||||
const rawData = this.storage[key]
|
||||
let data_ = {} as TransactionCheckpoint
|
||||
if (rawData) {
|
||||
data_ = rawData as TransactionCheckpoint
|
||||
@@ -437,13 +441,37 @@ export class InMemoryDistributedTransactionStorage
|
||||
} as Parameters<typeof this.get>[1]
|
||||
|
||||
data_ =
|
||||
(await this.get(key, getOptions)) ??
|
||||
(await getCheckpoint(getOptions as TransactionOptions)) ??
|
||||
({ flow: {} } as TransactionCheckpoint)
|
||||
}
|
||||
|
||||
const { flow: latestUpdatedFlow } = data_
|
||||
if (options?.stepId) {
|
||||
const stepId = options.stepId
|
||||
const currentStep = data.flow.steps[stepId]
|
||||
const latestStep = latestUpdatedFlow.steps?.[stepId]
|
||||
if (latestStep && currentStep) {
|
||||
const isCompensating = data.flow.state === TransactionState.COMPENSATING
|
||||
|
||||
if (!isInitialCheckpoint && !isPresent(latestUpdatedFlow)) {
|
||||
const latestState = isCompensating
|
||||
? latestStep.compensate?.state
|
||||
: latestStep.invoke?.state
|
||||
|
||||
const shouldSkip = doneStates.includes(latestState)
|
||||
|
||||
if (shouldSkip) {
|
||||
throw new SkipStepAlreadyFinishedError(
|
||||
`Step ${stepId} already finished by another execution`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isInitialCheckpoint &&
|
||||
!isPresent(latestUpdatedFlow) &&
|
||||
!data.flow.metadata?.parentStepIdempotencyKey
|
||||
) {
|
||||
/**
|
||||
* the initial checkpoint expect no other checkpoint to have been stored.
|
||||
* In case it is not the initial one and another checkpoint is trying to
|
||||
@@ -453,54 +481,7 @@ export class InMemoryDistributedTransactionStorage
|
||||
throw new SkipExecutionError("Already finished by another execution")
|
||||
}
|
||||
|
||||
let currentFlowLatestExecutedStep: TransactionStep | undefined
|
||||
const currentFlowSteps = Object.values(currentFlow.steps || {})
|
||||
for (let i = currentFlowSteps.length - 1; i >= 0; i--) {
|
||||
if (currentFlowSteps[i].lastAttempt) {
|
||||
currentFlowLatestExecutedStep = currentFlowSteps[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let latestUpdatedFlowLatestExecutedStep: TransactionStep | undefined
|
||||
const latestUpdatedFlowSteps = Object.values(latestUpdatedFlow.steps || {})
|
||||
for (let i = latestUpdatedFlowSteps.length - 1; i >= 0; i--) {
|
||||
if (latestUpdatedFlowSteps[i].lastAttempt) {
|
||||
latestUpdatedFlowLatestExecutedStep = latestUpdatedFlowSteps[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The current flow and the latest updated flow have the same latest executed step.
|
||||
*/
|
||||
const isSameLatestExecutedStep =
|
||||
currentFlowLatestExecutedStep &&
|
||||
latestUpdatedFlowLatestExecutedStep &&
|
||||
currentFlowLatestExecutedStep?.id ===
|
||||
latestUpdatedFlowLatestExecutedStep?.id
|
||||
|
||||
/**
|
||||
* The current flow's latest executed step has a last attempt ahead of the latest updated
|
||||
* flow's latest executed step. Therefor it is fine, otherwise another execution has already
|
||||
* finished the step.
|
||||
*/
|
||||
const isCurrentLatestExecutedStepLastAttemptAhead =
|
||||
currentFlowLatestExecutedStep?.lastAttempt &&
|
||||
latestUpdatedFlowLatestExecutedStep?.lastAttempt &&
|
||||
currentFlowLatestExecutedStep.lastAttempt >=
|
||||
latestUpdatedFlowLatestExecutedStep.lastAttempt
|
||||
|
||||
if (
|
||||
isSameLatestExecutedStep &&
|
||||
!isCurrentLatestExecutedStepLastAttemptAhead
|
||||
) {
|
||||
throw new SkipStepAlreadyFinishedError(
|
||||
"Step already in execution ahead of the current one"
|
||||
)
|
||||
}
|
||||
|
||||
// First ensure that the latest execution was not cancelled, otherwise we skip the execution
|
||||
// Ensure that the latest execution was not cancelled, otherwise we skip the execution
|
||||
const latestTransactionCancelledAt = latestUpdatedFlow.cancelledAt
|
||||
const currentTransactionCancelledAt = currentFlow.cancelledAt
|
||||
|
||||
@@ -512,86 +493,6 @@ export class InMemoryDistributedTransactionStorage
|
||||
"Workflow execution has been cancelled during the execution"
|
||||
)
|
||||
}
|
||||
|
||||
const currentFlowLastInvokingStepIndex =
|
||||
currentFlowSteps.findIndex(isInvokingState)
|
||||
|
||||
let latestUpdatedFlowLastInvokingStepIndex = !latestUpdatedFlow.steps
|
||||
? 1 // There is no other execution, so the current execution is the latest
|
||||
: -1
|
||||
|
||||
if (latestUpdatedFlow.steps) {
|
||||
for (let i = 0; i < latestUpdatedFlowSteps.length; i++) {
|
||||
if (isInvokingState(latestUpdatedFlowSteps[i])) {
|
||||
latestUpdatedFlowLastInvokingStepIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let currentFlowLastCompensatingStepIndex = -1
|
||||
for (let i = currentFlowSteps.length - 1; i >= 0; i--) {
|
||||
if (isCompensatingState(currentFlowSteps[i])) {
|
||||
currentFlowLastCompensatingStepIndex = currentFlowSteps.length - 1 - i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let latestUpdatedFlowLastCompensatingStepIndex = !latestUpdatedFlow.steps
|
||||
? -1 // There is no other execution, so the current execution is the latest
|
||||
: -1
|
||||
|
||||
if (latestUpdatedFlow.steps) {
|
||||
for (let i = latestUpdatedFlowSteps.length - 1; i >= 0; i--) {
|
||||
if (isCompensatingState(latestUpdatedFlowSteps[i])) {
|
||||
latestUpdatedFlowLastCompensatingStepIndex =
|
||||
latestUpdatedFlowSteps.length - 1 - i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isLatestExecutionFinishedIndex = -1
|
||||
const invokeShouldBeSkipped =
|
||||
(latestUpdatedFlowLastInvokingStepIndex ===
|
||||
isLatestExecutionFinishedIndex ||
|
||||
currentFlowLastInvokingStepIndex <
|
||||
latestUpdatedFlowLastInvokingStepIndex) &&
|
||||
currentFlowLastInvokingStepIndex !== isLatestExecutionFinishedIndex
|
||||
|
||||
const compensateShouldBeSkipped =
|
||||
currentFlowLastCompensatingStepIndex <
|
||||
latestUpdatedFlowLastCompensatingStepIndex &&
|
||||
currentFlowLastCompensatingStepIndex !== isLatestExecutionFinishedIndex &&
|
||||
latestUpdatedFlowLastCompensatingStepIndex !==
|
||||
isLatestExecutionFinishedIndex
|
||||
|
||||
const isCompensatingMismatch =
|
||||
latestUpdatedFlow.state === TransactionState.COMPENSATING &&
|
||||
![TransactionState.REVERTED, TransactionState.FAILED].includes(
|
||||
currentFlow.state
|
||||
) &&
|
||||
currentFlow.state !== latestUpdatedFlow.state
|
||||
|
||||
const isRevertedMismatch =
|
||||
latestUpdatedFlow.state === TransactionState.REVERTED &&
|
||||
currentFlow.state !== TransactionState.REVERTED
|
||||
|
||||
const isFailedMismatch =
|
||||
latestUpdatedFlow.state === TransactionState.FAILED &&
|
||||
currentFlow.state !== TransactionState.FAILED
|
||||
|
||||
if (
|
||||
(data.flow.state !== TransactionState.COMPENSATING &&
|
||||
invokeShouldBeSkipped) ||
|
||||
(data.flow.state === TransactionState.COMPENSATING &&
|
||||
compensateShouldBeSkipped) ||
|
||||
isCompensatingMismatch ||
|
||||
isRevertedMismatch ||
|
||||
isFailedMismatch
|
||||
) {
|
||||
throw new SkipExecutionError("Already finished by another execution")
|
||||
}
|
||||
}
|
||||
|
||||
async scheduleRetry(
|
||||
@@ -850,7 +751,7 @@ export class InMemoryDistributedTransactionStorage
|
||||
updated_at: {
|
||||
$lte: raw(
|
||||
(alias) =>
|
||||
`CURRENT_TIMESTAMP - (INTERVAL '1 second' * retention_time)`
|
||||
`CURRENT_TIMESTAMP - (INTERVAL '1 second' * "retention_time")`
|
||||
),
|
||||
},
|
||||
state: {
|
||||
|
||||
Reference in New Issue
Block a user