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
@@ -57,17 +57,7 @@ import {
} from "../__fixtures__/workflow_1_manual_retry_step"
import { TestDatabase } from "../utils"
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)
function times(num) {
let resolver
@@ -109,6 +99,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
testSuite: ({ service: workflowOrcModule, medusaApp }) => {
describe("Workflow Orchestrator module", function () {
beforeEach(async () => {
await TestDatabase.clearTables()
jest.clearAllMocks()
query = medusaApp.query
@@ -169,14 +160,14 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
describe("Testing basic workflow", function () {
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 setTimeout(500)
await setTimeout(2000)
return new StepResponse("step2")
})
@@ -197,43 +188,37 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
}
)
workflowOrcModule
.run(workflowId, {
input: {},
const onFinishPromise = new Promise<void>((resolve) => {
workflowOrcModule.subscribe({
workflowId,
transactionId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
resolve()
}
},
})
.then(async () => {
await setTimeout(100)
})
await workflowOrcModule.cancel(workflowId, {
transactionId,
})
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
})
workflowOrcModule.subscribe({
workflowId,
transactionId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
const execution =
await workflowOrcModule.listWorkflowExecutions({
transaction_id: transactionId,
})
await setTimeout(100)
expect(execution.length).toEqual(1)
expect(execution[0].state).toEqual(
TransactionState.REVERTED
)
done()
clearTimeout(timeout)
}
},
})
})
await workflowOrcModule.cancel(workflowId, {
transactionId,
})
const timeout = failTrap(
done,
"should cancel an ongoing execution with async unfinished yet step"
)
await onFinishPromise
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 () => {
@@ -270,19 +255,29 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
}
)
const onFinishPromise = new Promise<void>((resolve) => {
workflowOrcModule.subscribe({
workflowId,
transactionId,
subscriber: (event) => {
if (event.eventType === "onFinish") {
resolve()
}
},
})
})
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
})
await setTimeout(100)
await onFinishPromise
await workflowOrcModule.cancel(workflowId, {
transactionId,
})
await setTimeout(500)
const execution = await workflowOrcModule.listWorkflowExecutions({
transaction_id: transactionId,
})
@@ -397,116 +392,95 @@ 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
.run(workflowId, {
input: {},
transactionId,
})
.then(() => {
expect(step1InvokeMockManualRetry).toHaveBeenCalledTimes(1)
expect(step2InvokeMockManualRetry).toHaveBeenCalledTimes(1)
void workflowOrcModule.retryStep({
idempotencyKey: {
workflowId,
transactionId,
stepId: "step_2",
action: "invoke",
},
})
})
workflowOrcModule.subscribe({
workflowId,
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
expect(step1InvokeMockManualRetry).toHaveBeenCalledTimes(1)
expect(step2InvokeMockManualRetry).toHaveBeenCalledTimes(2)
done()
clearTimeout(timeout)
}
})
const onFinishPromise = new Promise<void>((resolve) => {
workflowOrcModule.subscribe({
workflowId,
transactionId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
expect(step1InvokeMockManualRetry).toHaveBeenCalledTimes(1)
expect(step2InvokeMockManualRetry).toHaveBeenCalledTimes(2)
resolve()
}
},
})
})
expect(step1InvokeMockManualRetry).toHaveBeenCalledTimes(1)
expect(step2InvokeMockManualRetry).toHaveBeenCalledTimes(1)
await workflowOrcModule.retryStep({
idempotencyKey: {
workflowId,
transactionId,
stepId: "step_2",
action: "invoke",
},
})
const timeout = failTrap(
done,
"should manually retry a step that is taking too long to finish"
)
await onFinishPromise
})
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) => {
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", async () => {
const transactionId = "transaction-auto-retries" + ulid()
const workflowId = "workflow_1_auto_retries_false"
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
throwOnError: false,
})
const onFinishPromise = new Promise<void>((resolve, reject) => {
workflowOrcModule.subscribe({
workflowId,
transactionId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
try {
expect(
step1InvokeMockAutoRetriesFalse
).toHaveBeenCalledTimes(1)
expect(
step2InvokeMockAutoRetriesFalse
).toHaveBeenCalledTimes(3)
expect(
step1CompensateMockAutoRetriesFalse
).toHaveBeenCalledTimes(1)
expect(
step2CompensateMockAutoRetriesFalse
).toHaveBeenCalledTimes(1)
resolve()
} catch (error) {
reject(error)
}
resolve()
}
},
})
})
await workflowOrcModule.run(workflowId, {
input: {},
transactionId,
throwOnError: false,
})
expect(step1InvokeMockAutoRetriesFalse).toHaveBeenCalledTimes(1)
expect(step2InvokeMockAutoRetriesFalse).toHaveBeenCalledTimes(1)
expect(step1CompensateMockAutoRetriesFalse).toHaveBeenCalledTimes(0)
@@ -529,6 +503,11 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
})
await onFinishPromise
expect(step1InvokeMockAutoRetriesFalse).toHaveBeenCalledTimes(1)
expect(step2InvokeMockAutoRetriesFalse).toHaveBeenCalledTimes(3)
expect(step1CompensateMockAutoRetriesFalse).toHaveBeenCalledTimes(1)
expect(step2CompensateMockAutoRetriesFalse).toHaveBeenCalledTimes(1)
})
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 () => {
@@ -585,8 +564,6 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
})
expect(executionsList).toHaveLength(1)
console.log(">>>>>>>>> setting step success")
const { result } = await workflowOrcModule.setStepSuccess({
idempotencyKey: {
action: TransactionHandlerType.INVOKE,
@@ -597,7 +574,6 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
stepResponse: { uhuuuu: "yeaah!" },
})
console.log(">>>>>>>>> setting step success done")
;({ data: executionsList } = await query.graph({
entity: "workflow_executions",
fields: ["id"],
@@ -928,41 +904,52 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
).toBe(true)
})
it("should complete an async workflow that returns a StepResponse", (done) => {
it("should complete an async workflow that returns a StepResponse", async () => {
const transactionId = "transaction_1" + ulid()
workflowOrcModule
.run("workflow_async_background", {
const onFinishPromise = new Promise<void>((resolve) => {
void workflowOrcModule.subscribe({
workflowId: "workflow_async_background",
transactionId,
subscriber: (event) => {
if (event.eventType === "onFinish") {
resolve()
}
},
})
})
const { transaction, result } = await workflowOrcModule.run(
"workflow_async_background",
{
input: {
myInput: "123",
},
transactionId,
throwOnError: true,
})
.then(({ transaction, result }: any) => {
expect(transaction.flow.state).toEqual(
TransactionStepState.INVOKING
)
expect(result).toEqual(undefined)
})
}
)
void workflowOrcModule.subscribe({
workflowId: "workflow_async_background",
transactionId,
subscriber: (event) => {
if (event.eventType === "onFinish") {
done()
clearTimeout(timeout)
}
},
})
expect(transaction.flow.state).toEqual(TransactionStepState.INVOKING)
expect(result).toEqual(undefined)
const timeout = failTrap(done, "workflow_async_background")
await onFinishPromise
})
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()
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", {
input: {
@@ -972,25 +959,24 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
throwOnError: false,
})
void workflowOrcModule.subscribe({
workflowId: "workflow_async_background",
transactionId,
subscriber: (event) => {
if (event.eventType === "onFinish") {
onFinish()
done()
clearTimeout(timeout)
}
},
})
expect(onFinish).toHaveBeenCalledTimes(0)
const timeout = failTrap(done, "workflow_async_background")
await onFinishPromise
})
it("should not skip step if condition is true", function (done) {
it("should not skip step if condition is true", async () => {
const transactionId = "trx_123_when" + ulid()
const onFinishPromise = new Promise<void>((resolve) => {
void workflowOrcModule.subscribe({
workflowId: "wf-when",
transactionId,
subscriber: (event) => {
if (event.eventType === "onFinish") {
resolve()
}
},
})
})
void workflowOrcModule.run("wf-when", {
input: {
callSubFlow: true,
@@ -1000,23 +986,30 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
logOnError: true,
})
void workflowOrcModule.subscribe({
workflowId: "wf-when",
transactionId,
subscriber: (event) => {
if (event.eventType === "onFinish") {
done()
clearTimeout(timeout)
}
},
})
const timeout = failTrap(done, "wf-when")
await onFinishPromise
})
it("should cancel an async sub workflow when compensating", (done) => {
it("should cancel an async sub workflow when compensating", async () => {
const workflowId = "workflow_async_background_fail"
const transactionId = "trx_123_compensate_async_sub_workflow" + ulid()
let onCompensateStepSuccess: { step: TransactionStep } | null = null
const onFinishPromise = new Promise<void>((resolve) => {
void workflowOrcModule.subscribe({
workflowId,
transactionId,
subscriber: (event) => {
if (event.eventType === "onCompensateStepSuccess") {
onCompensateStepSuccess = event
}
if (event.eventType === "onFinish") {
resolve()
}
},
})
})
void workflowOrcModule.run(workflowId, {
input: {
callSubFlow: true,
@@ -1026,31 +1019,16 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
logOnError: false,
})
let onCompensateStepSuccess: { step: TransactionStep } | null = null
await onFinishPromise
void workflowOrcModule.subscribe({
workflowId,
subscriber: (event) => {
if (event.eventType === "onCompensateStepSuccess") {
onCompensateStepSuccess = event
}
if (event.eventType === "onFinish") {
expect(onCompensateStepSuccess).toBeDefined()
expect(onCompensateStepSuccess!.step.id).toEqual(
"_root.nested_sub_flow_async_fail-as-step" // The workflow as step
)
expect(onCompensateStepSuccess!.step.compensate).toEqual({
state: "reverted",
status: "ok",
})
done()
clearTimeout(timeout)
}
},
expect(onCompensateStepSuccess).toBeDefined()
expect(onCompensateStepSuccess!.step.id).toEqual(
"_root.nested_sub_flow_async_fail-as-step" // The workflow as step
)
expect(onCompensateStepSuccess!.step.compensate).toEqual({
state: "reverted",
status: "ok",
})
const timeout = failTrap(done, "workflow_async_background_fail")
})
it("should cancel and revert a completed workflow", async () => {
@@ -1,32 +1,20 @@
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__"
import { TestDatabase } from "../utils"
import { TestDatabase } from "../utils/database"
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)
}
// REF:https://stackoverflow.com/questions/78028715/jest-async-test-with-event-emitter-isnt-ending
jest.setTimeout(20000)
moduleIntegrationTestRunner<IWorkflowEngineService>({
moduleName: Modules.WORKFLOW_ENGINE,
@@ -38,14 +26,244 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
},
testSuite: ({ service: workflowOrcModule, medusaApp }) => {
describe("Testing race condition of the workflow during retry", () => {
beforeEach(async () => {
await TestDatabase.clearTables()
jest.clearAllMocks()
})
afterEach(async () => {
await TestDatabase.clearTables()
})
it("should prevent race continuation of the workflow during retryIntervalAwaiting in background execution", (done) => {
const transactionId = "transaction_id" + ulid()
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()
const subWorkflowId = "sub-" + workflowId
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 sync steps in concurrency", async () => {
const step0 = createStep({ name: "step0" }, async () => {
return new StepResponse("result from step 0")
})
const step1 = createStep({ name: "step1" }, async () => {
return new StepResponse("result from step 1")
})
const step2 = createStep({ name: "step2" }, async () => {
return new StepResponse("result from step 2")
})
const step3 = createStep({ name: "step3" }, async () => {
return new StepResponse("result from step 3")
})
const step4 = createStep({ name: "step4" }, 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()
@@ -59,7 +277,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
const step1 = createStep("step1", async (_) => {
step1InvokeMock()
await setTimeout(2000)
await setTimeout(1000)
return new StepResponse({ isSuccess: true })
})
@@ -68,70 +286,70 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
return new StepResponse({ result: input })
})
const subWorkflow = createWorkflow(subWorkflowId, function () {
const subWorkflow = createWorkflow("sub-workflow-1", function () {
const status = step1()
return new WorkflowResponse(status)
})
createWorkflow(workflowId, 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,
transactionId,
subscriber: async (event) => {
if (event.eventType === "onFinish") {
try {
expect(step0InvokeMock).toHaveBeenCalledTimes(1)
expect(
step1InvokeMock.mock.calls.length
).toBeGreaterThanOrEqual(1)
expect(step2InvokeMock).toHaveBeenCalledTimes(1)
expect(transformMock).toHaveBeenCalledTimes(1)
// Prevent killing the test to early
await setTimeout(500)
done()
} catch (e) {
return done(e)
} finally {
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(workflowId, { transactionId })
.run(workflowId, {
transactionId,
throwOnError: false,
logOnError: true,
})
.then(({ result }) => {
expect(result).toBe("result from step 0")
})
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) => {
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()
@@ -157,7 +375,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
"RACE_step1",
async (_) => {
step1InvokeMock()
await setTimeout(500)
await setTimeout(1000)
throw new Error("error from step 1")
},
() => {
@@ -175,61 +393,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,
transactionId,
subscriber: (event) => {
if (event.eventType === "onFinish") {
try {
expect(step0InvokeMock).toHaveBeenCalledTimes(1)
expect(step0CompensateMock).toHaveBeenCalledTimes(1)
expect(
step1InvokeMock.mock.calls.length
).toBeGreaterThanOrEqual(2) // Called every 0.1s at least (it can take more than 0.1sdepending on the event loop congestions)
expect(step1CompensateMock).toHaveBeenCalledTimes(1)
expect(step2InvokeMock).toHaveBeenCalledTimes(0)
expect(transformMock).toHaveBeenCalledTimes(0)
done()
} catch (e) {
return done(e)
} finally {
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
.run(workflowId, { transactionId, throwOnError: false })
await workflowOrcModule
.run(workflowId, {
transactionId,
throwOnError: false,
logOnError: true,
})
.then(({ result }) => {
expect(result).toBe("result from step 0")
})
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)
})
})
},
@@ -30,20 +30,16 @@ async function deleteKeysByPattern(pattern) {
count: 100,
})
const pipeline = redis.pipeline()
for await (const keys of stream) {
if (keys.length) {
const pipeline = redis.pipeline()
keys.forEach((key) => pipeline.unlink(key))
await pipeline.exec()
}
}
await pipeline.exec()
}
async function cleanRedis() {
try {
await deleteKeysByPattern("bull:*")
await deleteKeysByPattern("dtrx:*")
} catch (error) {
console.error("Error:", error)
}
await deleteKeysByPattern("bull:*")
await deleteKeysByPattern("dtrx:*")
}
@@ -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 --runInBand -- integration-tests/**/__tests__/index.spec.ts && jest --forceExit --runInBand -- integration-tests/**/__tests__/race.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",
@@ -14,6 +14,7 @@ import {
} from "@medusajs/framework/types"
import {
isString,
MedusaError,
promiseAll,
TransactionState,
} from "@medusajs/framework/utils"
@@ -114,7 +115,7 @@ export class WorkflowOrchestratorService {
protected redisPublisher: Redis
protected redisSubscriber: Redis
protected container_: MedusaContainer
private subscribers: Subscribers = new Map()
private static subscribers: Subscribers = new Map()
readonly #logger: Logger
@@ -153,7 +154,7 @@ export class WorkflowOrchestratorService {
this.redisSubscriber.on("message", async (channel, message) => {
const workflowId = channel.split(":")[1]
if (!this.subscribers.has(workflowId)) return
if (!WorkflowOrchestratorService.subscribers.has(workflowId)) return
try {
const { instanceId, data } = JSON.parse(message)
@@ -177,9 +178,17 @@ export class WorkflowOrchestratorService {
await this.redisDistributedTransactionStorage_.onApplicationStart()
}
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 = [
@@ -190,7 +199,7 @@ export class WorkflowOrchestratorService {
if (hasFailed) {
await this.setStepFailure({
idempotencyKey: parentStepIdempotencyKey,
stepResponse: result,
stepResponse: errors,
options: {
logOnError: true,
},
@@ -224,13 +233,16 @@ export class WorkflowOrchestratorService {
throwOnError ??= true
context ??= {}
context.transactionId = transactionId ?? ulid()
context.transactionId = transactionId ?? "auto-" + ulid()
const workflowId = isString(workflowIdOrWorkflow)
? workflowIdOrWorkflow
: workflowIdOrWorkflow.getName()
if (!workflowId) {
throw new Error("Workflow ID is required")
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Workflow ID is required`
)
}
const events: FlowRunOptions["events"] = this.buildWorkflowEvents({
@@ -241,11 +253,14 @@ export class WorkflowOrchestratorService {
const exportedWorkflow = MedusaWorkflow.getWorkflow(workflowId)
if (!exportedWorkflow) {
throw new Error(`Workflow with id "${workflowId}" not found.`)
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Workflow with id "${workflowId}" not found.`
)
}
const { onFinish, ...restEvents } = events
const originalOnFinishHandler = events.onFinish!
delete events.onFinish
const ret = await exportedWorkflow.run({
input,
@@ -253,7 +268,7 @@ export class WorkflowOrchestratorService {
logOnError,
resultFrom,
context,
events,
events: restEvents,
container: container ?? this.container_,
})
@@ -283,7 +298,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)) {
@@ -327,7 +342,10 @@ export class WorkflowOrchestratorService {
const exportedWorkflow = MedusaWorkflow.getWorkflow(workflowId)
if (!exportedWorkflow) {
throw new Error(`Workflow with id "${workflowId}" not found.`)
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Workflow with id "${workflowId}" not found.`
)
}
const transaction = await this.getRunningTransaction(
@@ -354,12 +372,15 @@ export class WorkflowOrchestratorService {
transactionId: transactionId,
})
const { onFinish, ...restEvents } = events
const originalOnFinishHandler = events.onFinish!
const ret = await exportedWorkflow.cancel({
transaction,
throwOnError: false,
logOnError,
context,
events,
events: restEvents,
container: container ?? this.container_,
})
@@ -382,17 +403,13 @@ export class WorkflowOrchestratorService {
if (hasFinished) {
const { result, errors } = ret
this.notify({
isFlowAsync: ret.transaction.getFlow().hasAsyncSteps,
eventType: "onFinish",
workflowId,
transactionId: transaction.transactionId,
state: transactionState as TransactionState,
await originalOnFinishHandler({
transaction: ret.transaction,
result,
errors,
})
await this.triggerParentStep(ret.transaction, result)
await this.triggerParentStep(ret.transaction, result, errors)
}
if (throwOnError && (ret.thrownError || ret.errors?.length)) {
@@ -464,29 +481,28 @@ export class WorkflowOrchestratorService {
workflowId,
})
const { onFinish, ...restEvents } = events
const originalOnFinishHandler = events.onFinish!
const ret = await exportedWorkflow.retryStep({
idempotencyKey: idempotencyKey_,
context,
throwOnError: false,
logOnError,
events,
events: restEvents,
container: container ?? this.container_,
})
if (ret.transaction.hasFinished()) {
const { result, errors } = ret
this.notify({
isFlowAsync: ret.transaction.getFlow().hasAsyncSteps,
eventType: "onFinish",
workflowId,
transactionId,
state: ret.transaction.getFlow().state as TransactionState,
await originalOnFinishHandler({
transaction: ret.transaction,
result,
errors,
})
await this.triggerParentStep(ret.transaction, result)
await this.triggerParentStep(ret.transaction, result, errors)
}
if (throwOnError && (ret.thrownError || ret.errors?.length)) {
@@ -534,13 +550,16 @@ export class WorkflowOrchestratorService {
workflowId,
})
const { onFinish, ...restEvents } = events
const originalOnFinishHandler = events.onFinish!
const ret = await exportedWorkflow.registerStepSuccess({
idempotencyKey: idempotencyKey_,
context,
resultFrom,
throwOnError: false,
logOnError,
events,
events: restEvents,
response: stepResponse,
container: container ?? this.container_,
})
@@ -548,17 +567,13 @@ export class WorkflowOrchestratorService {
if (ret.transaction.hasFinished()) {
const { result, errors } = ret
this.notify({
isFlowAsync: ret.transaction.getFlow().hasAsyncSteps,
eventType: "onFinish",
workflowId,
transactionId,
state: ret.transaction.getFlow().state as TransactionState,
await originalOnFinishHandler({
transaction: ret.transaction,
result,
errors,
})
await this.triggerParentStep(ret.transaction, result)
await this.triggerParentStep(ret.transaction, result, errors)
}
if (throwOnError && (ret.thrownError || ret.errors?.length)) {
@@ -607,13 +622,16 @@ export class WorkflowOrchestratorService {
workflowId,
})
const { onFinish, ...restEvents } = events
const originalOnFinishHandler = events.onFinish!
const ret = await exportedWorkflow.registerStepFailure({
idempotencyKey: idempotencyKey_,
context,
resultFrom,
throwOnError: false,
logOnError,
events,
events: restEvents,
response: stepResponse,
container: container ?? this.container_,
forcePermanentFailure,
@@ -622,17 +640,13 @@ export class WorkflowOrchestratorService {
if (ret.transaction.hasFinished()) {
const { result, errors } = ret
this.notify({
isFlowAsync: ret.transaction.getFlow().hasAsyncSteps,
eventType: "onFinish",
workflowId,
transactionId,
state: ret.transaction.getFlow().state as TransactionState,
await originalOnFinishHandler({
transaction: ret.transaction,
result,
errors,
})
await this.triggerParentStep(ret.transaction, result)
await this.triggerParentStep(ret.transaction, result, errors)
}
if (throwOnError && (ret.thrownError || ret.errors?.length)) {
@@ -653,10 +667,11 @@ export class WorkflowOrchestratorService {
subscriberId,
}: SubscribeOptions) {
subscriber._id = subscriberId
const subscribers = this.subscribers.get(workflowId) ?? new Map()
const subscribers =
WorkflowOrchestratorService.subscribers.get(workflowId) ?? new Map()
// Subscribe instance to redis
if (!this.subscribers.has(workflowId)) {
if (!WorkflowOrchestratorService.subscribers.has(workflowId)) {
void this.redisSubscriber.subscribe(this.getChannelName(workflowId))
}
@@ -675,7 +690,7 @@ export class WorkflowOrchestratorService {
transactionSubscribers.push(subscriber)
subscribers.set(transactionId, transactionSubscribers)
this.subscribers.set(workflowId, subscribers)
WorkflowOrchestratorService.subscribers.set(workflowId, subscribers)
return
}
@@ -687,7 +702,7 @@ export class WorkflowOrchestratorService {
workflowSubscribers.push(subscriber)
subscribers.set(AnySubscriber, workflowSubscribers)
this.subscribers.set(workflowId, subscribers)
WorkflowOrchestratorService.subscribers.set(workflowId, subscribers)
}
unsubscribe({
@@ -695,7 +710,7 @@ export class WorkflowOrchestratorService {
transactionId,
subscriberOrId,
}: UnsubscribeOptions) {
const subscribers = this.subscribers.get(workflowId)
const subscribers = WorkflowOrchestratorService.subscribers.get(workflowId)
if (!subscribers) {
return
}
@@ -735,7 +750,7 @@ export class WorkflowOrchestratorService {
}
if (subscribers.size === 0) {
this.subscribers.delete(workflowId)
WorkflowOrchestratorService.subscribers.delete(workflowId)
void this.redisSubscriber.unsubscribe(this.getChannelName(workflowId))
}
}
@@ -774,7 +789,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) => {
@@ -886,6 +901,9 @@ export class WorkflowOrchestratorService {
await notify({
eventType: "onFinish",
isFlowAsync: transaction.getFlow().hasAsyncSteps,
result,
errors,
state: transaction.getFlow().state as TransactionState,
})
},
@@ -36,24 +36,22 @@ enum JobType {
const THIRTY_MINUTES_IN_MS = 1000 * 60 * 30
const REPEATABLE_CLEARER_JOB_ID = "clear-expired-executions"
const invokingStatesSet = new Set([
TransactionStepState.INVOKING,
TransactionStepState.NOT_STARTED,
])
const doneStates = [
TransactionStepState.DONE,
TransactionStepState.REVERTED,
TransactionStepState.FAILED,
TransactionStepState.SKIPPED,
TransactionStepState.SKIPPED_FAILURE,
TransactionStepState.TIMEOUT,
]
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)
}
const finishedStates = [
TransactionState.DONE,
TransactionState.FAILED,
TransactionState.REVERTED,
]
const failedStates = [TransactionState.FAILED, TransactionState.REVERTED]
export class RedisDistributedTransactionStorage
implements IDistributedTransactionStorage, IDistributedSchedulerStorage
{
@@ -152,11 +150,17 @@ export class RedisDistributedTransactionStorage
} with the following data: ${JSON.stringify(job.data)}`
)
if (allowedJobs.includes(job.name as JobType)) {
await this.executeTransaction(
job.data.workflowId,
job.data.transactionId,
job.data.transactionMetadata
)
try {
await this.executeTransaction(
job.data.workflowId,
job.data.transactionId,
job.data.transactionMetadata
)
} catch (error) {
if (!SkipExecutionError.isSkipExecutionError(error)) {
throw error
}
}
}
if (job.name === JobType.SCHEDULE) {
@@ -273,29 +277,12 @@ export class RedisDistributedTransactionStorage
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[]
@@ -334,7 +321,8 @@ export class RedisDistributedTransactionStorage
if (
!(isNotStarted || isFinished || isWaitingToCompensate) &&
!currentStepsIsAsync
!currentStepsIsAsync &&
!asyncVersion
) {
return
}
@@ -440,10 +428,7 @@ export class RedisDistributedTransactionStorage
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
@@ -461,11 +446,11 @@ export class RedisDistributedTransactionStorage
}
}
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
@@ -476,94 +461,120 @@ export class RedisDistributedTransactionStorage
data: TransactionCheckpoint,
ttl?: number,
options?: TransactionOptions
): Promise<void> {
): Promise<TransactionCheckpoint> {
/**
* 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,
})
let lockAcquired = false
if (hasFinished && retentionTime) {
Object.assign(data, {
retention_time: retentionTime,
})
}
if (data.flow._v) {
lockAcquired = await this.#acquireLock(key)
// Only set if not exists
const shouldSetNX =
data.flow.state === TransactionState.NOT_STARTED &&
!data.flow.transactionId.startsWith("auto-")
// Prepare operations to be executed in batch or pipeline
const data_ = {
errors: data.errors,
flow: data.flow,
}
const stringifiedData = JSON.stringify(data_)
const pipeline = this.redisClient.pipeline()
// Execute Redis operations
if (!hasFinished) {
if (ttl) {
if (shouldSetNX) {
pipeline.set(key, stringifiedData, "EX", ttl, "NX")
} else {
pipeline.set(key, stringifiedData, "EX", ttl)
}
} else {
if (shouldSetNX) {
pipeline.set(key, stringifiedData, "NX")
} else {
pipeline.set(key, stringifiedData)
}
if (!lockAcquired) {
throw new Error("Lock not acquired")
}
} else {
pipeline.unlink(key)
const storedData = await this.get(key, {
isCancelling: !!data.flow.cancelledAt,
} as any)
TransactionCheckpoint.mergeCheckpoints(data, storedData)
}
const execPipeline = () => {
return pipeline.exec().then((result) => {
if (!shouldSetNX) {
return result
}
try {
const hasFinished = finishedStates.includes(data.flow.state)
const actionResult = result?.pop()
const isOk = !!actionResult?.pop()
if (!isOk) {
throw new SkipExecutionError(
"Transaction already started for transactionId: " +
data.flow.transactionId
)
let cachedCheckpoint: TransactionCheckpoint | undefined
const getCheckpoint = async (options?: TransactionOptions) => {
if (!cachedCheckpoint) {
cachedCheckpoint = await this.get(key, options)
}
return cachedCheckpoint
}
return result
await this.#preventRaceConditionExecutionIfNecessary({
data: data,
key,
options,
getCheckpoint,
})
}
// Database operations
if (hasFinished && !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 (!data.flow.metadata?.parentStepIdempotencyKey) {
await promiseAll([execPipeline(), this.deleteFromDb(data)])
// Only set if not exists
const shouldSetNX =
data.flow.state === TransactionState.NOT_STARTED &&
!data.flow.transactionId.startsWith("auto-")
if (retentionTime) {
Object.assign(data, {
retention_time: retentionTime,
})
}
const execPipeline = () => {
const lightData_ = {
errors: data.errors,
flow: data.flow,
}
const stringifiedData = JSON.stringify(lightData_)
const pipeline = this.redisClient.pipeline()
if (!hasFinished) {
if (ttl) {
if (shouldSetNX) {
pipeline.set(key, stringifiedData, "EX", ttl, "NX")
} else {
pipeline.set(key, stringifiedData, "EX", ttl)
}
} else {
if (shouldSetNX) {
pipeline.set(key, stringifiedData, "NX")
} else {
pipeline.set(key, stringifiedData)
}
}
} else {
pipeline.unlink(key)
}
return pipeline.exec().then((result) => {
if (!shouldSetNX) {
return result
}
const actionResult = result?.pop()
const isOk = !!actionResult?.pop()
if (!isOk) {
throw new SkipExecutionError(
"Transaction already started for transactionId: " +
data.flow.transactionId
)
}
return result
})
}
if (hasFinished && !retentionTime) {
if (!data.flow.metadata?.parentStepIdempotencyKey) {
await this.deleteFromDb(data)
await execPipeline()
} else {
await this.saveToDb(data, retentionTime)
await execPipeline()
}
} else {
await this.saveToDb(data, retentionTime)
await execPipeline()
}
} else {
await this.saveToDb(data, retentionTime)
await execPipeline()
return data as TransactionCheckpoint
} finally {
if (lockAcquired) {
await this.#releaseLock(key)
}
}
}
@@ -750,19 +761,47 @@ export class RedisDistributedTransactionStorage
)
}
/**
* Generate a lock key for the given transaction key
*/
#getLockKey(key: string): string {
return `${key}:lock`
}
async #acquireLock(key: string, ttlSeconds: number = 2): Promise<boolean> {
const lockKey = this.#getLockKey(key)
const result = await this.redisClient.set(
lockKey,
1,
"EX",
ttlSeconds,
"NX"
)
return result === "OK"
}
async #releaseLock(key: string): Promise<void> {
const lockKey = this.#getLockKey(key)
await this.redisClient.del(lockKey)
}
async #preventRaceConditionExecutionIfNecessary({
data,
key,
options,
getCheckpoint,
}: {
data: TransactionCheckpoint
key: string
options?: TransactionOptions
getCheckpoint: (
options: TransactionOptions
) => Promise<TransactionCheckpoint | undefined>
}) {
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
@@ -780,13 +819,37 @@ export class RedisDistributedTransactionStorage
} 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
@@ -796,54 +859,7 @@ export class RedisDistributedTransactionStorage
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 finished by another execution"
)
}
// 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
@@ -855,91 +871,6 @@ export class RedisDistributedTransactionStorage
"Workflow execution has been cancelled during the execution"
)
}
let currentFlowLastInvokingStepIndex = -1
for (let i = 0; i < currentFlowSteps.length; i++) {
if (isInvokingState(currentFlowSteps[i])) {
currentFlowLastInvokingStepIndex = i
break
}
}
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 clearExpiredExecutions() {